Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f | ||
|
|
9207ec563c | ||
|
|
44e0e17203 | ||
|
|
b9c9901643 | ||
|
|
a201c1617b | ||
|
|
d24f402b0c | ||
|
|
5cfc905f10 | ||
|
|
4531cfe47c | ||
|
|
927a3b8b11 | ||
|
|
4ad8396adc | ||
|
|
6aaeb34272 | ||
|
|
ab57966a94 | ||
|
|
18d9b9adf6 | ||
|
|
ed311d79e3 | ||
|
|
457400ceee | ||
|
|
88d87be685 | ||
|
|
5ddb2acfc7 | ||
|
|
5f150f039d | ||
|
|
4f54f768e8 | ||
|
|
c24762c7a3 | ||
|
|
3e85e07363 | ||
|
|
facef97fcc | ||
|
|
89d580b90d | ||
|
|
734155c939 | ||
|
|
790d7084af | ||
|
|
60efb047d8 | ||
|
|
f9052eb23f | ||
|
|
9c5daa3918 | ||
|
|
23870801fc | ||
|
|
ae793551ba | ||
|
|
f19aaf6207 | ||
|
|
d0cc439fd1 | ||
|
|
f42f57b8d0 | ||
|
|
0d4b588fd0 | ||
|
|
6a725dde58 | ||
|
|
6499da6281 | ||
|
|
b2379fb03f | ||
|
|
b29e71124d | ||
|
|
63593e2146 | ||
|
|
10cbe7a637 | ||
|
|
79418f013a | ||
|
|
4024732696 | ||
|
|
ffad3ea78b | ||
|
|
a9b9aeb44d | ||
|
|
3d76fe6d75 | ||
|
|
58cafe28ad | ||
|
|
60f9a3dc4d | ||
|
|
11d9db5580 | ||
|
|
05d3f0b469 | ||
|
|
c3f60448ad | ||
|
|
523711cca3 | ||
|
|
bcb487810a | ||
|
|
7e1d644f7d | ||
|
|
4aac65d7d2 | ||
|
|
14ecae61dc | ||
|
|
c53ff5a30a | ||
|
|
5aad97d64e | ||
|
|
081c865018 | ||
|
|
050f38b1fa | ||
|
|
d657bdfa75 | ||
|
|
bbf724d9e1 | ||
|
|
9d8c6044e3 | ||
|
|
2d17ca71e3 | ||
|
|
d77dd1e3e1 | ||
|
|
9d97b74684 | ||
|
|
c4673acb65 | ||
|
|
1bdc51dcc7 | ||
|
|
6ee0f6a40a | ||
|
|
9ee25c930e | ||
|
|
1b2c70a90b | ||
|
|
cdea876992 | ||
|
|
16557830ae | ||
|
|
8836f9cdca | ||
|
|
26bfe9cb6c | ||
|
|
05eaaad61f | ||
|
|
2e0852b272 | ||
|
|
6173a9ad13 | ||
|
|
e3560d9cb4 | ||
|
|
52326326e2 | ||
|
|
2734fa08b7 | ||
|
|
67a0d622bc | ||
|
|
58968a1cbf | ||
|
|
01c8afbfe8 | ||
|
|
be2e0f2b68 | ||
|
|
aa40c8be26 | ||
|
|
b3d4c9241c | ||
|
|
34dd5122b3 | ||
|
|
dab3606b04 | ||
|
|
0f7638055c | ||
|
|
66fe7fd359 | ||
|
|
1b2ee7da3a | ||
|
|
f6bec519f4 | ||
|
|
7102add194 | ||
|
|
a3d894730b | ||
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:github.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
# Bulwark Webmail — Development Configuration
|
# Bulwark Webmail - Development Configuration
|
||||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||||
# No external mail server required — great for UI development and testing.
|
# No external mail server required - great for UI development and testing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.dev.example .env.local
|
# cp .env.dev.example .env.local
|
||||||
# npm run dev
|
# npm run dev
|
||||||
# Open http://localhost:3000 — log in with any username/password.
|
# Open http://localhost:3000 - log in with any username/password.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock JMAP Server
|
# Mock JMAP Server
|
||||||
|
|||||||
+69
-18
@@ -1,4 +1,4 @@
|
|||||||
# Bulwark Webmail — Production Configuration
|
# Bulwark Webmail - Production Configuration
|
||||||
# Copy this file to .env.local and fill in your values.
|
# Copy this file to .env.local and fill in your values.
|
||||||
# For development with the built-in mock server, see .env.dev.example instead.
|
# For development with the built-in mock server, see .env.dev.example instead.
|
||||||
|
|
||||||
@@ -6,12 +6,19 @@
|
|||||||
# JMAP Server (required)
|
# JMAP Server (required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# App name displayed in the UI
|
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||||
|
|
||||||
|
# Allow users to specify a custom JMAP server URL on the login form.
|
||||||
|
# When enabled, a "JMAP Server" field appears on the login page.
|
||||||
|
# Users can connect to any JMAP-compatible server.
|
||||||
|
# NOTE: External JMAP servers must include this domain in their CORS
|
||||||
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -41,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# OAuth client secret (server-side only, never exposed to the browser)
|
# OAuth client secret (server-side only, never exposed to the browser)
|
||||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
# Alternatively, you can specify the path to a file containing the OAuth client secret.
|
||||||
|
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
|
||||||
|
|
||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
@@ -53,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Required for both "Remember me" and settings sync features.
|
# Required for both "Remember me" and settings sync features.
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
# SESSION_SECRET=your-secret-key-here
|
# SESSION_SECRET=your-secret-key-here
|
||||||
|
# Alternatively, you can specify the path to a file containing the session secret.
|
||||||
|
# SESSION_SECRET_FILE=/session-secret
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Settings Sync
|
# Settings Sync
|
||||||
@@ -65,7 +76,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, the working directory is /app, so the default resolves to
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
# /app/data/settings — mount a persistent volume there:
|
# /app/data/settings - mount a persistent volume there:
|
||||||
# volumes:
|
# volumes:
|
||||||
# - bulwark-settings:/app/data/settings
|
# - bulwark-settings:/app/data/settings
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
@@ -95,41 +106,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Branding (all optional)
|
# Branding (all optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Custom favicon for the browser tab.
|
# ---------------------------------------------------------------------------
|
||||||
|
# App identity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Short name for the app, used in contexts where space is limited
|
||||||
|
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||||
|
# APP_SHORT_NAME=Bulwark
|
||||||
|
|
||||||
|
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||||
|
# Defaults to a generic Bulwark description if not set.
|
||||||
|
# APP_DESCRIPTION=Your personal webmail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Icons & favicon
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Custom favicon shown in the browser tab.
|
||||||
# Supported formats: SVG (recommended), PNG, ICO.
|
# Supported formats: SVG (recommended), PNG, ICO.
|
||||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
# Can be an absolute URL or a path relative to the public/ directory.
|
|
||||||
# Defaults to the Bulwark favicon if not set.
|
# Defaults to the Bulwark favicon if not set.
|
||||||
# FAVICON_URL=/branding/my-favicon.svg
|
# FAVICON_URL=/branding/my-favicon.svg
|
||||||
|
|
||||||
# Custom logos for the sidebar (shown in the main app after login).
|
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||||
|
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||||
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
|
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||||
|
# PWA_ICON_URL=/branding/my-icon.svg
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PWA appearance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||||
|
# (address bar, status bar on Android). Default: #ffffff
|
||||||
|
# PWA_THEME_COLOR=#3b82f6
|
||||||
|
|
||||||
|
# Background color shown on the PWA splash screen while the app is loading.
|
||||||
|
# Should match your app's main background color. Default: #ffffff
|
||||||
|
# PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Logos shown in the sidebar (main app, after login).
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 24×24px, max 128×128px
|
# Recommended size: min 24×24px, max 128×128px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# If not set, no logo is shown in the sidebar.
|
# If not set, no logo is shown in the sidebar.
|
||||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||||
|
|
||||||
# Custom logo images for the login page.
|
# Logos shown on the login page.
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 32×32px, max 512×512px
|
# Recommended size: min 32×32px, max 512×512px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
#
|
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
|
||||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
|
|
||||||
# Company or organization name displayed above the version on the login page
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Company name shown above the version number on the login page.
|
||||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL for the imprint/legal notice link on the login page
|
# URL for the imprint / legal notice link on the login page.
|
||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
|
||||||
# URL for the privacy policy link on the login page
|
# URL for the privacy policy link on the login page.
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
|
||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Bulwark Webmail – Funding configuration
|
||||||
|
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||||
|
|
||||||
|
github: [bulwarkmail]
|
||||||
@@ -21,11 +21,23 @@ on:
|
|||||||
- ".github/workflows/docker-publish.yml"
|
- ".github/workflows/docker-publish.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image_name: ${{ steps.set.outputs.image_name }}
|
||||||
|
steps:
|
||||||
|
- name: Set image name
|
||||||
|
id: set
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: prepare
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -57,7 +69,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
|
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
@@ -66,7 +78,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -86,7 +98,7 @@ jobs:
|
|||||||
|
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: [prepare, build]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -113,17 +125,17 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value={{branch}}
|
type=raw,value=latest
|
||||||
type=sha,prefix={{branch}}-
|
type=sha
|
||||||
|
|
||||||
- name: Create manifest list and push
|
- name: Create manifest list and push
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
|
||||||
|
|
||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Publish Standalone Tarball on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
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
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
ARCH: ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
VERSION="${REF_NAME#v}"
|
||||||
|
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p bulwark-standalone
|
||||||
|
cp -r .next/standalone/. bulwark-standalone/
|
||||||
|
cp -r .next/static bulwark-standalone/.next/static
|
||||||
|
cp -r public bulwark-standalone/public
|
||||||
|
|
||||||
|
tar -czf "$TARBALL" bulwark-standalone/
|
||||||
|
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload release asset
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||||
|
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
|
||||||
|
|
||||||
|
- name: Upload artifact (workflow_dispatch)
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: standalone-${{ matrix.arch }}
|
||||||
|
path: ${{ env.TARBALL }}
|
||||||
|
retention-days: 7
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Check for AI attribution in commit message
|
|
||||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
|
||||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
|
||||||
echo " This violates project policy in CLAUDE.md"
|
|
||||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+195
-2
@@ -1,5 +1,198 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.14 (2026-04-16)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- [@pr0ton11](https://github.com/pr0ton11)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||||
|
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||||
|
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||||
|
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||||
|
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||||
|
- **PWA**: Show app name and logo in install prompt
|
||||||
|
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||||
|
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||||
|
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||||
|
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||||
|
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||||
|
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||||
|
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||||
|
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||||
|
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||||
|
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||||
|
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||||
|
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||||
|
|
||||||
|
## 1.4.13 (2026-04-12)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
|
||||||
|
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
|
||||||
|
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
|
||||||
|
- **PWA**: Add "don't remind me again" option to install prompt
|
||||||
|
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
|
||||||
|
- **Plugins**: Add `onAvatarResolve` plugin hook
|
||||||
|
- **Docker**: Publish main and dev branches as separate GHCR packages
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Style links in plain text emails
|
||||||
|
- **Email**: Seed list history entry when app initializes on an email view
|
||||||
|
- **Email**: Remount composer on draft edit and preserve identity (#60)
|
||||||
|
- **Contacts**: Display contact names stored in `name.full` (#179)
|
||||||
|
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
|
||||||
|
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
|
||||||
|
- **Search**: Search all folders when filtering emails by tag (#175)
|
||||||
|
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
|
||||||
|
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
|
||||||
|
|
||||||
|
## 1.4.12 (2026-04-09)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **PWA**: Add PWA support with service worker and install prompt
|
||||||
|
- **Calendar**: Add birthday calendar feature with settings and localization
|
||||||
|
- **Calendar**: Clamp February 29 birthdays in non-leap years
|
||||||
|
- **Identity**: Add automatic identity synchronization (#167)
|
||||||
|
- **Plugins**: Disable plugins by default and require admin approval
|
||||||
|
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
|
||||||
|
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
|
||||||
|
- **Email**: Sync mail view to browser history for back/forward navigation
|
||||||
|
- **Contacts**: Add ability to rename address books (#152)
|
||||||
|
- **UI**: Add version badge in settings
|
||||||
|
- **i18n**: Add Latvian (lv) locale support
|
||||||
|
- **i18n**: Add Polish language support
|
||||||
|
- **i18n**: Add Korean language support
|
||||||
|
- **i18n**: Add Simplified Chinese (zh_CN) locale support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
|
||||||
|
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
|
||||||
|
- **Email**: Fix logic for marking email as read in EmailViewer
|
||||||
|
- **Email**: Fix archive action passing MouseEvent as argument
|
||||||
|
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
|
||||||
|
- **Mailbox**: Align shared account folders with primary folders (#151)
|
||||||
|
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
|
||||||
|
- **Mailbox**: Improve mailbox deletion error handling
|
||||||
|
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
|
||||||
|
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
|
||||||
|
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
|
||||||
|
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
|
||||||
|
- **Files**: Prune recent files against server nodes on refresh (#146)
|
||||||
|
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
|
||||||
|
- **Files**: Extend file drop zone to fill remaining viewport height
|
||||||
|
- **Files**: Fallback to application/octet-stream for long MIME types
|
||||||
|
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
|
||||||
|
- **Security**: Validate plugin HTTP post URL against origin with regression tests
|
||||||
|
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
|
||||||
|
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
|
||||||
|
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
|
||||||
|
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
|
||||||
|
- **API**: Handle 403 on principal fetch without console error
|
||||||
|
- **API**: Enhance error handling in Stalwart API responses
|
||||||
|
|
||||||
|
## 1.4.11 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Logging**: Add logging categories for better log management
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
|
||||||
|
- **Security**: Harden proxy authentication and SSRF defenses
|
||||||
|
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
|
||||||
|
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
|
||||||
|
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
|
||||||
|
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
|
||||||
|
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
|
||||||
|
- **Files**: Update file feature disabled messages and add stability warnings
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||||
|
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||||
|
- **Calendar**: Add iCal subscription editing and batch event import
|
||||||
|
- **Calendar**: Add hover preview settings and functionality
|
||||||
|
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||||
|
- **Email**: Add reply-to addresses support in email composer
|
||||||
|
- **Email**: Add mail layout settings and update email list components
|
||||||
|
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||||
|
- **Email**: Enhance compose functionality with button integration and translations
|
||||||
|
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||||
|
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||||
|
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||||
|
- **Settings**: Add folder expansion state management and settings navigation
|
||||||
|
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||||
|
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||||
|
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||||
|
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||||
|
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||||
|
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||||
|
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||||
|
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||||
|
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||||
|
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||||
|
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||||
|
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||||
|
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||||
|
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||||
|
- **Email**: Match hover action background to selected row state
|
||||||
|
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||||
|
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||||
|
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||||
|
- **UI**: Improve dark mode handling for media elements and background images
|
||||||
|
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||||
|
- **UI**: Fix nested button in theme card
|
||||||
|
|
||||||
## 1.4.9 (2026-03-27)
|
## 1.4.9 (2026-03-27)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -287,7 +480,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||||
|
|
||||||
## 1.1.1 (2026-02-28)
|
## 1.1.1 (2026-02-28)
|
||||||
@@ -297,7 +490,7 @@
|
|||||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||||
|
|
||||||
## 1.1.0 (2026-02-28)
|
## 1.1.0 (2026-02-28)
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -1,9 +1,9 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
|
|
||||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||||
|
|
||||||
|
## Join our Community
|
||||||
|
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
|
||||||
|
|
||||||
|
* **Get Support:** Get real-time help with development hurdles.
|
||||||
|
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||||
|
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||||
|
|
||||||
|
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
@@ -195,7 +206,7 @@ webmail/
|
|||||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default for privacy
|
- **Block external content** by default for privacy
|
||||||
- Report security vulnerabilities privately
|
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||||
|
|
||||||
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||||
|
|
||||||
**Calendar** — Month, week, day, and agenda views with event management
|
**Calendar** - Month, week, day, and agenda views with event management
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||||
|
|
||||||
**Contacts** — Contact management with groups and vCard support
|
**Contacts** - Contact management with groups and vCard support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||||
|
|
||||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||||
|
|
||||||
**Light mode** — Full theme support with intelligent color transformation
|
**Light mode** - Full theme support with intelligent color transformation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||||
|
|
||||||
**Settings** — Appearance, identities, filters, templates, and more
|
**Settings** - Appearance, identities, filters, templates, and more
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||||
|
|
||||||
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
@@ -94,34 +94,46 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
### Mail
|
### Mail
|
||||||
|
|
||||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||||
- **Draft auto-save** with discard confirmation
|
- **Unified mailbox** - view emails across all accounts in a single list
|
||||||
- **Attachments** — upload, download, and inline preview
|
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||||
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||||
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||||
- **Archive modes** — archive directly or organize archived mail by year or month
|
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||||
|
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||||
- **Print** emails directly from the viewer
|
- **Print** emails directly from the viewer
|
||||||
- **Answered/forwarded status icons** in email list and thread views
|
- **Answered/forwarded status icons** in email list and thread views
|
||||||
- **Color tags/labels** and star/unstar
|
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||||
|
- **Star/unstar** with configurable mark-as-read delay
|
||||||
- **Virtual scrolling** for large mailboxes
|
- **Virtual scrolling** for large mailboxes
|
||||||
- **Quick reply** from the viewer
|
- **Quick reply** from the viewer
|
||||||
- **Sender avatars** — favicon-based with negative caching for performance
|
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||||
|
- **Sender avatars** - favicon-based with negative caching for performance
|
||||||
- **Recipient popover** for quick contact interaction
|
- **Recipient popover** for quick contact interaction
|
||||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
- **Plain text composer mode** and auto-select reply identity
|
||||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
- **Reply-to addresses** support in the composer
|
||||||
- **Tag counts** — unread and total counts displayed in sidebar
|
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||||
|
- **message/rfc822 unwrapping** for embedded messages
|
||||||
|
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||||
|
- **Tag counts** - unread and total counts displayed in sidebar
|
||||||
|
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
|
|
||||||
- **Month, week, day, and agenda views** with mini-calendar sidebar
|
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
|
||||||
- **Event hover preview** popover with details
|
- **Event hover preview** popover with configurable details
|
||||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||||
- **iCalendar import** with preview and bulk create
|
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
- **iCal / webcal subscriptions** with editing and batch import
|
||||||
|
- **Birthday calendar** - auto-generated from contacts
|
||||||
|
- **Virtual locations** - video conference URLs as first-class event fields
|
||||||
|
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||||
|
- **Shared calendars** with visual grouping in the sidebar
|
||||||
|
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||||
- **Week numbers** in mini-calendar sidebar
|
- **Week numbers** in mini-calendar sidebar
|
||||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||||
- **Real-time sync** via JMAP push
|
- **Real-time sync** via JMAP push
|
||||||
@@ -129,77 +141,103 @@ Built with Next.js and the JMAP protocol.
|
|||||||
### Contacts
|
### Contacts
|
||||||
|
|
||||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||||
|
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||||
|
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||||
- **Contact groups** with group expansion and member management
|
- **Contact groups** with group expansion and member management
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||||
|
- **Trusted senders** stored in a dedicated JMAP address book
|
||||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||||
- **Bulk operations** — multi-select, delete, group add, export
|
- **Bulk operations** - multi-select, delete, group add, export
|
||||||
|
|
||||||
### Filters & Automation
|
### Filters & Automation
|
||||||
|
|
||||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||||
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||||
|
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||||
- **Raw Sieve editor** with syntax validation
|
- **Raw Sieve editor** with syntax validation
|
||||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||||
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||||
- **Upload and download** files with progress tracking and folder upload support
|
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||||
|
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||||
- **Grid and list views** with sorting by name, size, or date
|
- **Grid and list views** with sorting by name, size, or date
|
||||||
- **Clipboard operations** — cut, copy, paste, duplicate files
|
- **Clipboard operations** - cut, copy, paste, duplicate files
|
||||||
- **File preview** for images, text, audio, video, and more
|
- **File preview** for images, text, audio, video, and more
|
||||||
- **Favorites and recent files** for quick access
|
- **Favorites and recent files** for quick access
|
||||||
- **Bulk operations** — multi-select, delete, move, download
|
- **Bulk operations** - multi-select, delete, move, download
|
||||||
|
|
||||||
### Security & Privacy
|
### Security & Privacy
|
||||||
|
|
||||||
- **External content blocked** by default — trusted senders list for auto-load
|
- **External content blocked** by default - trusted senders list for auto-load
|
||||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||||
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
- **SPF/DKIM/DMARC** status indicators
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
|
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||||
- **TOTP two-factor authentication**
|
- **TOTP two-factor authentication**
|
||||||
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||||
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||||
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||||
|
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
- **Newsletter unsubscribe** (RFC 2369)
|
||||||
|
|
||||||
### Interface
|
### Interface
|
||||||
|
|
||||||
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||||
- **Dark and light themes** with intelligent email color transformation
|
- **Dark and light themes** with intelligent email color transformation
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||||
- **Keyboard shortcuts** — full navigation without a mouse
|
- **Keyboard shortcuts** - full navigation without a mouse
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||||
- **Interactive guided tour** — onboarding walkthrough for new users
|
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||||
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||||
- **Storage quota** display
|
- **Storage quota** display
|
||||||
- **Shared folders** — multi-account access
|
- **Version badge** in settings
|
||||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
- **Focused mode** with proper viewport bounds
|
||||||
|
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
|
|
||||||
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||||
|
|
||||||
Automatic browser detection with persistent preference.
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
### Identity Management
|
### Identity Management
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
- **Multiple sender identities** with per-identity signatures
|
||||||
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||||
- **Identity badges** in viewer and email list
|
- **Identity badges** in viewer and email list
|
||||||
|
|
||||||
|
### Multi-Account
|
||||||
|
|
||||||
|
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||||
|
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||||
|
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||||
|
- **Shared folders** across accounts
|
||||||
|
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
|
### Admin & Extensibility
|
||||||
|
|
||||||
|
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||||
|
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||||
|
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||||
|
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||||
|
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
- **Automatic update check** - server logs when a newer release is available
|
||||||
|
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||||
|
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||||
|
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -217,7 +255,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local - set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local - set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -252,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server Listen Address</summary>
|
||||||
@@ -271,6 +309,7 @@ PORT=3000 # Default listen port
|
|||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
|
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -279,13 +318,79 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Remember Me</summary>
|
<summary>Remember Me & Settings Sync</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||||
|
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||||
|
|
||||||
|
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||||
|
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
|
||||||
```
|
```
|
||||||
|
|
||||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||||
|
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Custom JMAP Endpoint</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||||
|
```
|
||||||
|
|
||||||
|
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Branding & PWA</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=My Webmail
|
||||||
|
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||||
|
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||||
|
|
||||||
|
FAVICON_URL=/branding/favicon.svg
|
||||||
|
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
|
||||||
|
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
|
||||||
|
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
|
||||||
|
|
||||||
|
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||||
|
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||||
|
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||||
|
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||||
|
|
||||||
|
LOGIN_COMPANY_NAME=My Company
|
||||||
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Extension Directory</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Stalwart Integration & Logging</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
STALWART_FEATURES=true # Password change, sieve filters, etc.
|
||||||
|
STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
|
||||||
|
|
||||||
|
LOG_FORMAT=text # "text" or "json"
|
||||||
|
LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -319,7 +424,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
|||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
if (!state || state !== savedState) {
|
if (!state || state !== savedState) {
|
||||||
setError("invalid_state");
|
setError("invalid_state");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
|
|||||||
@@ -39,10 +39,13 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
|||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
type PendingScopeAction =
|
type PendingScopeAction =
|
||||||
@@ -66,12 +69,13 @@ export default function CalendarPage() {
|
|||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
const contacts = useContactStore((s) => s.contacts);
|
||||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||||
|
|
||||||
const currentUserEmails = useMemo(() =>
|
const currentUserEmails = useMemo(() =>
|
||||||
@@ -82,6 +86,7 @@ export default function CalendarPage() {
|
|||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -107,6 +112,16 @@ export default function CalendarPage() {
|
|||||||
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
||||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
|
|
||||||
|
// Keep detailEvent in sync with store events (e.g. after update + refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (detailEvent) {
|
||||||
|
const updated = events.find(e => e.id === detailEvent.id);
|
||||||
|
if (updated && updated !== detailEvent) {
|
||||||
|
setDetailEvent(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [events, detailEvent]);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
@@ -146,6 +161,13 @@ export default function CalendarPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [client, refreshAllSubscriptions]);
|
}, [client, refreshAllSubscriptions]);
|
||||||
|
|
||||||
|
// Auto-add birthday calendar to selected IDs when enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||||
|
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||||
|
}
|
||||||
|
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const dateRange = useMemo(() => {
|
const dateRange = useMemo(() => {
|
||||||
const d = selectedDate;
|
const d = selectedDate;
|
||||||
switch (normalizedViewMode) {
|
switch (normalizedViewMode) {
|
||||||
@@ -337,18 +359,28 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
if (calendarHoverPreview === 'off') return;
|
||||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
// Don't show hover popover if the sidebar is already open for this event
|
// Don't show hover popover if the sidebar is already open for this event
|
||||||
if (showEventModal && editEvent?.id === event.id) return;
|
if (showEventModal && editEvent?.id === event.id) return;
|
||||||
setDetailEvent(event);
|
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||||
setDetailAnchorRect(anchorRect);
|
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||||
}, [isMobile, showEventModal, editEvent]);
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}, ms);
|
||||||
|
} else {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}
|
||||||
|
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||||
|
|
||||||
const handleHoverLeave = useCallback(() => {
|
const handleHoverLeave = useCallback(() => {
|
||||||
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
setDetailEvent(null);
|
setDetailEvent(null);
|
||||||
setDetailAnchorRect(null);
|
setDetailAnchorRect(null);
|
||||||
}, 200);
|
}, 300);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditFromDetail = useCallback(() => {
|
const handleEditFromDetail = useCallback(() => {
|
||||||
@@ -385,12 +417,16 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
if (!event.start) {
|
if (!event.start) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventDate = parseISO(event.start);
|
const eventDate = getEventStartDate({
|
||||||
|
start: event.start,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
});
|
||||||
if (Number.isNaN(eventDate.getTime())) {
|
if (Number.isNaN(eventDate.getTime())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -727,14 +763,31 @@ export default function CalendarPage() {
|
|||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const birthdayEvents = useMemo(() => {
|
||||||
events.filter((e) => {
|
if (!showBirthdayCalendar || !dateRange) return [];
|
||||||
|
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
|
||||||
|
}, [showBirthdayCalendar, contacts, dateRange]);
|
||||||
|
|
||||||
|
const birthdayCalendarName = (() => {
|
||||||
|
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const allCalendars = useMemo(() => {
|
||||||
|
if (!showBirthdayCalendar) return calendars;
|
||||||
|
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||||
|
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||||
|
|
||||||
|
const visibleEvents = useMemo(() => {
|
||||||
|
const filtered = events.filter((e) => {
|
||||||
if (!e.start || !e.calendarIds) return false;
|
if (!e.start || !e.calendarIds) return false;
|
||||||
const calIds = Object.keys(e.calendarIds);
|
const calIds = Object.keys(e.calendarIds);
|
||||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||||
}),
|
});
|
||||||
[events, selectedCalendarIds]
|
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||||
);
|
return [...filtered, ...birthdayEvents];
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hiddenEvents = events.filter((event) => {
|
const hiddenEvents = events.filter((event) => {
|
||||||
@@ -749,7 +802,7 @@ export default function CalendarPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('Calendar visibility summary', {
|
debug.log('calendar', 'Calendar visibility summary', {
|
||||||
totalEvents: events.length,
|
totalEvents: events.length,
|
||||||
visibleEvents: visibleEvents.length,
|
visibleEvents: visibleEvents.length,
|
||||||
hiddenEvents: hiddenEvents.length,
|
hiddenEvents: hiddenEvents.length,
|
||||||
@@ -783,7 +836,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarMonthView
|
<CalendarMonthView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectDate={handleSelectDate}
|
onSelectDate={handleSelectDate}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
@@ -799,7 +852,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarWeekView
|
<CalendarWeekView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectDate={handleSelectDate}
|
onSelectDate={handleSelectDate}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
@@ -818,7 +871,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarDayView
|
<CalendarDayView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
@@ -835,7 +888,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarAgendaView
|
<CalendarAgendaView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
@@ -873,7 +926,7 @@ export default function CalendarPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{viewContent}
|
{viewContent}
|
||||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||||
@@ -926,13 +979,18 @@ export default function CalendarPage() {
|
|||||||
showWeekNumbers={showWeekNumbers}
|
showWeekNumbers={showWeekNumbers}
|
||||||
/>
|
/>
|
||||||
<CalendarSidebarPanel
|
<CalendarSidebarPanel
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
onColorChange={client ? (calendarId, color) => {
|
onColorChange={client ? (calendarId, color) => {
|
||||||
|
if (calendarId === BIRTHDAY_CALENDAR_ID) {
|
||||||
|
updateSetting('birthdayCalendarColor', color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1099,6 +1157,18 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (() => {
|
||||||
|
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
|
||||||
|
if (!sub) return null;
|
||||||
|
return (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={sub}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<RecurrenceScopeDialog
|
<RecurrenceScopeDialog
|
||||||
isOpen={!!pendingScopeAction}
|
isOpen={!!pendingScopeAction}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
|||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, generateUUID } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
@@ -72,12 +73,16 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
renameAddressBook,
|
||||||
|
renameKeyword,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -274,7 +279,7 @@ export default function ContactsPage() {
|
|||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
} else {
|
} else {
|
||||||
const localContact: ContactCard = {
|
const localContact: ContactCard = {
|
||||||
id: `local-${crypto.randomUUID()}`,
|
id: `local-${generateUUID()}`,
|
||||||
addressBookIds: {},
|
addressBookIds: {},
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
@@ -631,6 +636,8 @@ export default function ContactsPage() {
|
|||||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
@@ -725,6 +732,46 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
{renamingKeyword !== null && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName={renamingKeyword}
|
||||||
|
title={t("rename_category")}
|
||||||
|
label={t("category_name_label")}
|
||||||
|
onCancel={() => setRenamingKeyword(null)}
|
||||||
|
onConfirm={async (newName) => {
|
||||||
|
try {
|
||||||
|
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||||
|
toast.success(t("category_renamed"));
|
||||||
|
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||||
|
setActiveCategory({ keyword: newName.trim() });
|
||||||
|
}
|
||||||
|
setRenamingKeyword(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename category:", err);
|
||||||
|
toast.error(t("category_rename_failed"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renamingAddressBook && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName={renamingAddressBook.name}
|
||||||
|
title={t("address_books.rename")}
|
||||||
|
label={t("address_books.name_label")}
|
||||||
|
onCancel={() => setRenamingAddressBook(null)}
|
||||||
|
onConfirm={async (newName) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await renameAddressBook(client, renamingAddressBook, newName);
|
||||||
|
toast.success(t("address_books.renamed"));
|
||||||
|
setRenamingAddressBook(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename address book:", err);
|
||||||
|
toast.error(t("address_books.rename_failed"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showImportDialog && (
|
{showImportDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
|||||||
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -392,12 +395,25 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{supportsFiles === false ? (
|
{!filesEnabled ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="max-w-lg text-center space-y-3 px-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : supportsFiles === false ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
|
||||||
|
</div>
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
@@ -442,6 +458,7 @@ export default function FilesPage() {
|
|||||||
onToggleDetails={handleToggleDetails}
|
onToggleDetails={handleToggleDetails}
|
||||||
detailResource={detailResource}
|
detailResource={detailResource}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+91
-17
@@ -10,13 +10,15 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.9";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -24,6 +26,41 @@ const THEME_OPTIONS = [
|
|||||||
{ value: "system" as const, icon: Monitor, label: "System" },
|
{ value: "system" as const, icon: Monitor, label: "System" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function VersionBadge() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex justify-center">
|
||||||
|
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||||
|
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label="Copy version info"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
@@ -32,13 +69,14 @@ export default function LoginPage() {
|
|||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
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 { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||||
const [totpCode, setTotpCode] = useState("");
|
const [totpCode, setTotpCode] = useState("");
|
||||||
const [showTotpField, setShowTotpField] = useState(false);
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -73,6 +111,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [appName, serverUrl]);
|
}, [appName, serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverUrl && !jmapEndpoint) {
|
||||||
|
setJmapEndpoint(serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -91,6 +135,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// Auto-show and focus TOTP field when server requires it
|
||||||
|
useEffect(() => {
|
||||||
|
if (error === 'totp_required') {
|
||||||
|
setShowTotpField(true);
|
||||||
|
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -180,8 +232,9 @@ export default function LoginPage() {
|
|||||||
const startServerSideSso = useCallback(async () => {
|
const startServerSideSso = useCallback(async () => {
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
try {
|
try {
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const res = await fetch('/api/auth/sso/start', {
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -202,7 +255,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
window.top!.location.href = authorize_url;
|
window.top!.location.href = authorize_url;
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restriction — fall back to current frame
|
// Cross-origin restriction - fall back to current frame
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -264,7 +317,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl && !demoMode) {
|
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -362,7 +415,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||||
if (isAddAccountMode) {
|
if (isAddAccountMode) {
|
||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
}
|
}
|
||||||
@@ -382,8 +435,9 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
formData.password,
|
||||||
totpCode || undefined,
|
totpCode || undefined,
|
||||||
@@ -563,9 +617,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -707,7 +759,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Dev mode — logging in as dev@localhost
|
Dev mode - logging in as dev@localhost
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : oauthOnly ? (
|
) : oauthOnly ? (
|
||||||
@@ -749,6 +801,27 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
|
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||||
|
{allowCustomJmapEndpoint && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_endpoint_label")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="jmap-endpoint"
|
||||||
|
type="url"
|
||||||
|
value={jmapEndpoint}
|
||||||
|
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||||
|
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder={t("jmap_endpoint_placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_endpoint_cors_hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username field */}
|
{/* Username field */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||||
@@ -861,7 +934,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
className={cn(
|
||||||
|
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||||
|
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||||
|
)}
|
||||||
placeholder={t("totp_placeholder")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
@@ -1035,9 +1111,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+341
-77
@@ -9,16 +9,20 @@ import { EmailComposer } from "@/components/email/email-composer";
|
|||||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -30,6 +34,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
@@ -37,6 +42,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
|||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
@@ -53,10 +59,12 @@ export default function Home() {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -74,6 +82,16 @@ export default function Home() {
|
|||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
useIdentitySync();
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||||
|
|
||||||
|
// Load trusted senders address book when feature is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||||
|
loadTrustedSendersBook(client);
|
||||||
|
}
|
||||||
|
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRateLimited || !rateLimitUntil) {
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
@@ -139,8 +157,150 @@ export default function Home() {
|
|||||||
hasMoreEmails,
|
hasMoreEmails,
|
||||||
fetchTagCounts,
|
fetchTagCounts,
|
||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
|
isUnifiedView,
|
||||||
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
|
refreshUnifiedCounts,
|
||||||
|
exitUnifiedView,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const connectedAccountsSignature = useMemo(
|
||||||
|
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||||
|
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||||
|
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||||
|
const result: UnifiedAccountClient[] = [];
|
||||||
|
for (const account of connected) {
|
||||||
|
const accountClient = clients.get(account.id);
|
||||||
|
if (!accountClient) continue;
|
||||||
|
result.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountLabel: account.label || account.email,
|
||||||
|
client: accountClient,
|
||||||
|
mailboxes: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const populateUnifiedAccountMailboxes = useCallback(
|
||||||
|
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||||
|
const populated = await Promise.all(
|
||||||
|
list.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const mailboxes = await entry.client.getMailboxes();
|
||||||
|
return { ...entry, mailboxes };
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return populated;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Browser back / forward integration. The restore handler reads the
|
||||||
|
// latest values from a ref so we don't have to recreate the callback on
|
||||||
|
// every render (and so the popstate listener is never stale).
|
||||||
|
const navRestoreStateRef = useRef({
|
||||||
|
client,
|
||||||
|
emails,
|
||||||
|
mailboxes,
|
||||||
|
selectedMailbox,
|
||||||
|
selectedEmailId: selectedEmail?.id ?? null,
|
||||||
|
conversationThreadId: null as string | null,
|
||||||
|
});
|
||||||
|
navRestoreStateRef.current.client = client;
|
||||||
|
navRestoreStateRef.current.emails = emails;
|
||||||
|
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||||
|
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||||
|
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||||
|
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
|
||||||
|
|
||||||
|
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
|
||||||
|
const ctx = navRestoreStateRef.current;
|
||||||
|
|
||||||
|
// Restore sidebar overlay state.
|
||||||
|
setSidebarOpen(state.sidebarOpen);
|
||||||
|
|
||||||
|
// Restore composer visibility.
|
||||||
|
if (!state.composerOpen) {
|
||||||
|
setShowComposer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the mobile view from the saved snapshot. The view is a
|
||||||
|
// function of which content the user is looking at: an email, a
|
||||||
|
// thread, the composer, or the bare list.
|
||||||
|
const derivedView: "list" | "viewer" =
|
||||||
|
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
|
||||||
|
setActiveView(derivedView);
|
||||||
|
|
||||||
|
// Restore mailbox selection. selectMailbox clears the current email,
|
||||||
|
// which is fine because we re-apply the saved email below.
|
||||||
|
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||||
|
selectMailbox(state.mailboxId);
|
||||||
|
if (ctx.client) {
|
||||||
|
try {
|
||||||
|
await fetchEmails(ctx.client, state.mailboxId);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to fetch emails on history restore:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore conversation thread (mobile only). We can clear it directly,
|
||||||
|
// but reopening requires the thread group; if the user pressed forward
|
||||||
|
// to return to a thread, we silently skip - back navigation always works.
|
||||||
|
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||||
|
if (state.threadId === null) {
|
||||||
|
setConversationThread(null);
|
||||||
|
setConversationEmails([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore email selection.
|
||||||
|
if (state.emailId !== ctx.selectedEmailId) {
|
||||||
|
if (state.emailId === null) {
|
||||||
|
selectEmail(null);
|
||||||
|
} else {
|
||||||
|
// Try the in-memory list first; the existing useEffect will fetch
|
||||||
|
// body content if it's missing.
|
||||||
|
const found = ctx.emails.find(e => e.id === state.emailId);
|
||||||
|
if (found) {
|
||||||
|
selectEmail(found);
|
||||||
|
} else if (ctx.client) {
|
||||||
|
// Email isn't in the current list (e.g. mailbox just changed).
|
||||||
|
// Fetch it directly.
|
||||||
|
try {
|
||||||
|
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
|
||||||
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
|
||||||
|
if (fullEmail) selectEmail(fullEmail);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to fetch email on history restore:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useBrowserNavigation({
|
||||||
|
mailboxId: selectedMailbox,
|
||||||
|
emailId: selectedEmail?.id ?? null,
|
||||||
|
threadId: conversationThread?.threadId ?? null,
|
||||||
|
composerOpen: showComposer,
|
||||||
|
sidebarOpen,
|
||||||
|
onRestore: handleNavRestore,
|
||||||
|
enabled: isAuthenticated && mailboxes.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts handlers
|
// Keyboard shortcuts handlers
|
||||||
const keyboardHandlers = useMemo(() => ({
|
const keyboardHandlers = useMemo(() => ({
|
||||||
onNextEmail: () => {
|
onNextEmail: () => {
|
||||||
@@ -350,13 +510,13 @@ export default function Home() {
|
|||||||
|
|
||||||
if (pushEnabled) {
|
if (pushEnabled) {
|
||||||
setPushConnected(true);
|
setPushConnected(true);
|
||||||
debug.log('[Push] Push notifications successfully enabled');
|
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||||
} else {
|
} else {
|
||||||
debug.log('[Push] Push notifications not available on this server');
|
debug.log('push', '[Push] Push notifications not available on this server');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Push notifications are optional - don't break the app if they fail
|
// Push notifications are optional - don't break the app if they fail
|
||||||
debug.log('[Push] Failed to setup push notifications:', error);
|
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading email data:', error);
|
console.error('Error loading email data:', error);
|
||||||
@@ -373,13 +533,30 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
||||||
|
|
||||||
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
|
// or the primary account's mailboxes change (a proxy for "something worth
|
||||||
|
// recounting happened").
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
if (built.length < 2) return;
|
||||||
|
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
});
|
||||||
|
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||||
|
|
||||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEmail || !client) return;
|
if (!selectedEmail || !client) return;
|
||||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||||
if (!selectedEmail.bodyValues) {
|
if (!selectedEmail.bodyValues) {
|
||||||
|
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||||
setLoadingEmail(false);
|
setLoadingEmail(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -390,7 +567,7 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear any existing timeout when email changes
|
// Clear any existing timeout when email changes
|
||||||
if (markAsReadTimeoutRef.current) {
|
if (markAsReadTimeoutRef.current) {
|
||||||
debug.log('[Mark as Read] Clearing previous timeout');
|
debug.log('email', '[Mark as Read] Clearing previous timeout');
|
||||||
clearTimeout(markAsReadTimeoutRef.current);
|
clearTimeout(markAsReadTimeoutRef.current);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -402,20 +579,20 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current setting value
|
// Get current setting value
|
||||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||||
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||||
|
|
||||||
if (markAsReadDelay === -1) {
|
if (markAsReadDelay === -1) {
|
||||||
// Never mark as read automatically
|
// Never mark as read automatically
|
||||||
debug.log('[Mark as Read] Never mode - email will stay unread');
|
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
|
||||||
} else if (markAsReadDelay === 0) {
|
} else if (markAsReadDelay === 0) {
|
||||||
// Mark as read instantly
|
// Mark as read instantly
|
||||||
debug.log('[Mark as Read] Instant mode - marking as read now');
|
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
|
||||||
markAsRead(client, selectedEmail.id, true);
|
markAsRead(client, selectedEmail.id, true);
|
||||||
} else {
|
} else {
|
||||||
// Mark as read after delay
|
// Mark as read after delay
|
||||||
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||||
markAsReadTimeoutRef.current = setTimeout(() => {
|
markAsReadTimeoutRef.current = setTimeout(() => {
|
||||||
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
|
||||||
markAsRead(client, selectedEmail.id, true);
|
markAsRead(client, selectedEmail.id, true);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}, markAsReadDelay);
|
}, markAsReadDelay);
|
||||||
@@ -424,7 +601,7 @@ export default function Home() {
|
|||||||
// Cleanup on unmount or when dependencies change
|
// Cleanup on unmount or when dependencies change
|
||||||
return () => {
|
return () => {
|
||||||
if (markAsReadTimeoutRef.current) {
|
if (markAsReadTimeoutRef.current) {
|
||||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
|
||||||
clearTimeout(markAsReadTimeoutRef.current);
|
clearTimeout(markAsReadTimeoutRef.current);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -439,7 +616,7 @@ export default function Home() {
|
|||||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||||
playNotificationSound(notificationSoundChoice);
|
playNotificationSound(notificationSoundChoice);
|
||||||
}
|
}
|
||||||
debug.log('New email received:', newEmailNotification.subject);
|
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||||
clearNewEmailNotification();
|
clearNewEmailNotification();
|
||||||
}
|
}
|
||||||
}, [newEmailNotification, clearNewEmailNotification]);
|
}, [newEmailNotification, clearNewEmailNotification]);
|
||||||
@@ -519,7 +696,10 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleEditDraft = async (email?: Email) => {
|
const handleEditDraft = async (email?: Email) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
let draft = email || selectedEmail;
|
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||||
|
? email
|
||||||
|
: selectedEmail;
|
||||||
|
let draft = draftCandidate;
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||||
@@ -538,6 +718,16 @@ export default function Home() {
|
|||||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||||
: undefined;
|
: 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;
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
setComposerSessionId(id => id + 1);
|
||||||
setPendingDraft({
|
setPendingDraft({
|
||||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
@@ -546,7 +736,7 @@ export default function Home() {
|
|||||||
body: htmlBody || bodyText,
|
body: htmlBody || bodyText,
|
||||||
showCc: (draft.cc?.length || 0) > 0,
|
showCc: (draft.cc?.length || 0) > 0,
|
||||||
showBcc: (draft.bcc?.length || 0) > 0,
|
showBcc: (draft.bcc?.length || 0) > 0,
|
||||||
selectedIdentityId: null,
|
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||||
subAddressTag: '',
|
subAddressTag: '',
|
||||||
mode: 'compose',
|
mode: 'compose',
|
||||||
draftId: draft.id,
|
draftId: draft.id,
|
||||||
@@ -568,8 +758,8 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
// Check if we're currently in the trash or junk folder
|
// Check if we're currently in the trash or junk folder
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
@@ -588,7 +778,7 @@ export default function Home() {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmail(client, selectedEmail.id, true);
|
await deleteEmail(client, emailToDelete.id, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to permanently delete email:", error);
|
console.error("Failed to permanently delete email:", error);
|
||||||
}
|
}
|
||||||
@@ -597,7 +787,7 @@ export default function Home() {
|
|||||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||||
if (trashMailbox) {
|
if (trashMailbox) {
|
||||||
try {
|
try {
|
||||||
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
|
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to move email to trash:", error);
|
console.error("Failed to move email to trash:", error);
|
||||||
}
|
}
|
||||||
@@ -636,7 +826,7 @@ export default function Home() {
|
|||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
// archiveMode === 'month' - find or create month subfolder under year
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
let monthMailbox = mailboxes.find(
|
let monthMailbox = mailboxes.find(
|
||||||
m => m.name === month && m.parentId === yearId
|
m => m.name === month && m.parentId === yearId
|
||||||
@@ -670,10 +860,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsSpam = async () => {
|
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToMark) return;
|
||||||
|
|
||||||
const emailId = selectedEmail.id;
|
const emailId = emailToMark.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await markAsSpam(client, emailId);
|
await markAsSpam(client, emailId);
|
||||||
@@ -701,11 +891,11 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoSpam = async () => {
|
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToRestore) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await undoSpam(client, selectedEmail.id);
|
await undoSpam(client, emailToRestore.id);
|
||||||
|
|
||||||
const toastInstance = (await import('sonner')).toast;
|
const toastInstance = (await import('sonner')).toast;
|
||||||
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
||||||
@@ -726,16 +916,22 @@ export default function Home() {
|
|||||||
|
|
||||||
const keywords = { ...email.keywords };
|
const keywords = { ...email.keywords };
|
||||||
|
|
||||||
// Remove old label and legacy color tags - set to false for JMAP to remove them
|
if (color === null) {
|
||||||
Object.keys(keywords).forEach(key => {
|
// Remove all label/color tags
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
Object.keys(keywords).forEach(key => {
|
||||||
keywords[key] = false;
|
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||||
|
keywords[key] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const jmapKey = `$label:${color}`;
|
||||||
|
if (keywords[jmapKey] === true) {
|
||||||
|
// Toggle off if already active
|
||||||
|
keywords[jmapKey] = false;
|
||||||
|
} else {
|
||||||
|
// Add the tag without disturbing others
|
||||||
|
keywords[jmapKey] = true;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Add new label tag if specified (using new $label: prefix)
|
|
||||||
if (color) {
|
|
||||||
keywords[`$label:${color}`] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update email keywords via JMAP
|
// Update email keywords via JMAP
|
||||||
@@ -755,6 +951,32 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMailboxSelect = async (mailboxId: string) => {
|
const handleMailboxSelect = async (mailboxId: string) => {
|
||||||
|
if (isUnifiedMailboxId(mailboxId)) {
|
||||||
|
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
selectMailbox(mailboxId);
|
||||||
|
selectEmail(null);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
const populated = await populateUnifiedAccountMailboxes(built);
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedView) {
|
||||||
|
exitUnifiedView();
|
||||||
|
}
|
||||||
|
|
||||||
selectMailbox(mailboxId);
|
selectMailbox(mailboxId);
|
||||||
selectEmail(null); // Clear selected email when switching mailboxes
|
selectEmail(null); // Clear selected email when switching mailboxes
|
||||||
|
|
||||||
@@ -838,6 +1060,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (!isFilterEmpty(searchFilters)) {
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
@@ -856,6 +1079,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleAdvancedSearch = async () => {
|
const handleAdvancedSearch = async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -865,9 +1089,9 @@ export default function Home() {
|
|||||||
clearTimeout(advancedSearchDebounceRef.current);
|
clearTimeout(advancedSearchDebounceRef.current);
|
||||||
}
|
}
|
||||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||||
if (client) advancedSearch(client);
|
if (client && !isUnifiedView) advancedSearch(client);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [client, advancedSearch]);
|
}, [client, advancedSearch, isUnifiedView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -967,6 +1191,10 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
|
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
const handleEmailSelect = async (email: { id: string }) => {
|
const handleEmailSelect = async (email: { id: string }) => {
|
||||||
@@ -992,13 +1220,29 @@ export default function Home() {
|
|||||||
|
|
||||||
// Fetch the full content
|
// Fetch the full content
|
||||||
try {
|
try {
|
||||||
// Find selected mailbox to determine accountId (for shared folders)
|
// In unified view each email carries its own accountId. Use that
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
// account's client so we fetch from the server that actually owns it.
|
||||||
// Only pass accountId for shared mailboxes
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||||
|
const perAccountClient = emailAccountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
|
|
||||||
const fullEmail = await client.getEmail(email.id, accountId);
|
// For shared folders on the primary client, we still need to pass the
|
||||||
|
// shared account's id. In unified view we use the per-account client
|
||||||
|
// directly, so no explicit accountId is needed.
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const accountId = perAccountClient
|
||||||
|
? undefined
|
||||||
|
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
|
||||||
|
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||||
if (fullEmail) {
|
if (fullEmail) {
|
||||||
|
if (emailAccountId) {
|
||||||
|
fullEmail.accountId = emailAccountId;
|
||||||
|
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||||
|
}
|
||||||
selectEmail(fullEmail);
|
selectEmail(fullEmail);
|
||||||
// Mark-as-read logic is now handled by useEffect
|
// Mark-as-read logic is now handled by useEffect
|
||||||
}
|
}
|
||||||
@@ -1009,14 +1253,24 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle back navigation from viewer on mobile
|
// Handle back navigation from viewer on mobile.
|
||||||
|
// Delegate to the browser history stack so this button is equivalent to
|
||||||
|
// the OS back button / mouse back button - popstate then restores the
|
||||||
|
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||||
|
// from a state that pushed history, so back() always lands on an app entry.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
// If in conversation view, clear it
|
if (typeof window !== 'undefined') {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
}
|
}
|
||||||
selectEmail(null);
|
selectEmail(null);
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1197,12 +1451,12 @@ export default function Home() {
|
|||||||
"max-md:flex-1 max-md:border-r-0",
|
"max-md:flex-1 max-md:border-r-0",
|
||||||
isMobile && activeView !== "list" && "max-md:hidden",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:flex-shrink-0 md:shadow-sm",
|
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
|
||||||
|
"md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
// Tablet: collapse when email selected
|
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -1252,6 +1506,8 @@ export default function Home() {
|
|||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
|
disabled={isUnifiedView}
|
||||||
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1267,13 +1523,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
|
disabled={isUnifiedView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title={t("advanced_search.toggle_filters")}
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -1457,8 +1715,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={async (email) => {
|
onDelete={async (email) => {
|
||||||
selectEmail(email);
|
await handleDelete(email);
|
||||||
await handleDelete();
|
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
await handleArchive(email);
|
await handleArchive(email);
|
||||||
@@ -1472,12 +1729,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMarkAsSpam={async (email) => {
|
onMarkAsSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleMarkAsSpam(email);
|
||||||
await handleMarkAsSpam();
|
|
||||||
}}
|
}}
|
||||||
onUndoSpam={async (email) => {
|
onUndoSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleUndoSpam(email);
|
||||||
await handleUndoSpam();
|
|
||||||
}}
|
}}
|
||||||
onEditDraft={(email) => {
|
onEditDraft={(email) => {
|
||||||
handleEditDraft(email);
|
handleEditDraft(email);
|
||||||
@@ -1487,24 +1742,27 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Compose Button (mobile) */}
|
{/* Floating Compose Button */}
|
||||||
{isMobile && (
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setComposerMode('compose');
|
||||||
setComposerMode('compose');
|
setShowComposer(true);
|
||||||
setShowComposer(true);
|
if (isMobile) setActiveView('viewer');
|
||||||
setActiveView('viewer');
|
}}
|
||||||
}}
|
className={cn(
|
||||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
"absolute z-40 rounded-full shadow-lg",
|
||||||
aria-label={t('sidebar.compose')}
|
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||||
>
|
)}
|
||||||
<PenSquare className="h-6 w-6" />
|
aria-label={t('sidebar.compose')}
|
||||||
</Button>
|
title={t('sidebar.compose_hint')}
|
||||||
)}
|
data-tour="compose-button"
|
||||||
|
>
|
||||||
|
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -1521,7 +1779,8 @@ export default function Home() {
|
|||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: relative
|
// Tablet/Desktop: relative
|
||||||
"md:relative"
|
"md:relative",
|
||||||
|
shouldHideViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane */}
|
||||||
@@ -1534,11 +1793,14 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmailComposer
|
<EmailComposer
|
||||||
|
key={composerSessionId}
|
||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
@@ -1636,7 +1898,7 @@ export default function Home() {
|
|||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
onMarkAsSpam={handleMarkAsSpam}
|
onMarkAsSpam={handleMarkAsSpam}
|
||||||
@@ -1648,14 +1910,15 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onBack={() => {
|
onBack={handleMobileBack}
|
||||||
setTabletListVisible(true);
|
|
||||||
selectEmail(null);
|
|
||||||
}}
|
|
||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
|
onCompose={() => {
|
||||||
|
setComposerMode('compose');
|
||||||
|
setShowComposer(true);
|
||||||
|
}}
|
||||||
currentUserEmail={client?.getUsername()}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
@@ -1709,6 +1972,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
|
|||||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||||
|
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||||
@@ -211,7 +212,7 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'encryption' && <SmimeSettings />}
|
{activeTab === 'encryption' && <SmimeSettings />}
|
||||||
{activeTab === 'vacation' && <VacationSettings />}
|
{activeTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||||
{activeTab === 'contacts' && <ContactsSettings />}
|
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||||
{activeTab === 'filters' && <FilterSettings />}
|
{activeTab === 'filters' && <FilterSettings />}
|
||||||
{activeTab === 'templates' && <TemplateSettings />}
|
{activeTab === 'templates' && <TemplateSettings />}
|
||||||
{activeTab === 'folders' && <FolderSettings />}
|
{activeTab === 'folders' && <FolderSettings />}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('slot', slot);
|
formData.append('slot', slot);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
|
|||||||
async function handleDeleteUpload(slot: string) {
|
async function handleDeleteUpload(slot: string) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slot }),
|
body: JSON.stringify({ slot }),
|
||||||
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/change-password', {
|
const res = await apiFetch('/api/admin/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
|||||||
+6
-10
@@ -24,8 +24,10 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -78,19 +80,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
function getJmapHeaders(): Record<string, string> {
|
||||||
const client = useAuthStore.getState().client;
|
return getActiveAccountSlotHeaders();
|
||||||
if (!client) return {};
|
|
||||||
return {
|
|
||||||
'Authorization': client.getAuthHeader(),
|
|
||||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
|
||||||
'X-JMAP-Username': client.getUsername(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
const jmapHeaders = getJmapHeaders();
|
const jmapHeaders = getJmapHeaders();
|
||||||
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const stalwartAdmin = data.stalwartAdmin === true;
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
@@ -109,7 +105,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
// If Stalwart admin but not yet authenticated, auto-login
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
if (stalwartAdmin) {
|
if (stalwartAdmin) {
|
||||||
const loginRes = await fetch('/api/admin/auth', {
|
const loginRes = await apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
@@ -127,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/auth', {
|
const res = await apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (actionFilter) params.set('action', actionFilter);
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/audit?${params}`);
|
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setEntries(data.entries || []);
|
setEntries(data.entries || []);
|
||||||
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDetail(detail: Record<string, unknown>): string {
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||||
if (detail.reason) return String(detail.reason);
|
if (detail.reason) return String(detail.reason);
|
||||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
if (detail.changes && Array.isArray(detail.changes)) {
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
|
|||||||
params.set('perPage', String(perPage));
|
params.set('perPage', String(perPage));
|
||||||
params.set('sort', 'newest');
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/marketplace?${params}`);
|
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
setError(data.error || 'Failed to connect to extension directory');
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/marketplace', {
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface AdminStatus {
|
interface AdminStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||||
fetch('/api/admin/auth'),
|
apiFetch('/api/admin/auth'),
|
||||||
fetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
fetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
fetch('/api/admin/config'),
|
apiFetch('/api/admin/config'),
|
||||||
fetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
fetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
fetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
const jmapRes = await fetch('/api/config');
|
const jmapRes = await apiFetch('/api/config');
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
} catch {
|
} catch {
|
||||||
setJmapHealth('error');
|
setJmapHealth('error');
|
||||||
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
|
|||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '—';
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="max-w-3xl space-y-8">
|
||||||
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Server Info */}
|
{/* Server Info */}
|
||||||
<SettingsSection title="Server" description="Application and connection details">
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
<SettingItem label="Application">
|
<SettingItem label="Application">
|
||||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Connection">
|
<SettingItem label="JMAP Connection">
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
type: string;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
configSchema?: Record<string, ConfigField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginConfigPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const pluginId = params.id as string;
|
||||||
|
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||||
|
const [config, setConfig] = useState<PluginConfig>({});
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
// Initialize form values from config + schema defaults when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const stored = config[key];
|
||||||
|
if (stored !== undefined && stored !== null) {
|
||||||
|
initial[key] = String(stored);
|
||||||
|
} else if (field.default !== undefined) {
|
||||||
|
initial[key] = String(field.default);
|
||||||
|
} else {
|
||||||
|
initial[key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormValues(initial);
|
||||||
|
}, [plugin, config]);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
|
apiFetch('/api/admin/plugins'),
|
||||||
|
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pluginsRes.ok) {
|
||||||
|
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||||
|
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
setConfig(await configRes.json());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
if (field.required && !formValues[key]?.trim()) {
|
||||||
|
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save each changed field
|
||||||
|
let hasError = false;
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const newVal = formValues[key] ?? '';
|
||||||
|
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||||
|
|
||||||
|
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
|
||||||
|
if (newVal === oldVal) continue;
|
||||||
|
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||||
|
|
||||||
|
// Convert types
|
||||||
|
let value: unknown = newVal;
|
||||||
|
if (field.type === 'boolean') value = newVal === 'true';
|
||||||
|
else if (field.type === 'number') value = Number(newVal);
|
||||||
|
|
||||||
|
// Delete if clearing a non-required field
|
||||||
|
if (!newVal && !field.required) {
|
||||||
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(hasError
|
||||||
|
? { type: 'error', text: 'Some settings failed to save' }
|
||||||
|
: { type: 'success', text: 'Configuration saved' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = plugin.configSchema;
|
||||||
|
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Puzzle className="w-5 h-5" />
|
||||||
|
{plugin.name} Configuration
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
v{plugin.version} by {plugin.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSchema ? (
|
||||||
|
<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">Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
{Object.entries(schema).map(([key, field]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</select>
|
||||||
|
) : field.type === 'select' && field.options ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">- Select -</option>
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'secret' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={revealSecrets[key] ? 'text' : 'password'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||||
|
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||||
|
>
|
||||||
|
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
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" /> : null}
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg p-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+40
-10
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface PluginEntry {
|
interface PluginEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,7 +35,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -59,11 +61,20 @@ export default function AdminPluginsPage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRequirePluginApproval() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
async function handleSavePolicy() {
|
||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -85,7 +96,7 @@ export default function AdminPluginsPage() {
|
|||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins');
|
const res = await apiFetch('/api/admin/plugins');
|
||||||
if (res.ok) setPlugins(await res.json());
|
if (res.ok) setPlugins(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -103,7 +114,7 @@ export default function AdminPluginsPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -126,7 +137,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function togglePlugin(id: string, enabled: boolean) {
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -146,7 +157,7 @@ export default function AdminPluginsPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -180,7 +191,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of disabled) {
|
for (const p of disabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
@@ -206,7 +217,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of enabled) {
|
for (const p of enabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
@@ -226,7 +237,7 @@ export default function AdminPluginsPage() {
|
|||||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
@@ -247,6 +258,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -319,6 +331,17 @@ export default function AdminPluginsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleRequirePluginApproval}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? '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 ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Force enable / disable all */}
|
{/* Force enable / disable all */}
|
||||||
{plugins.length > 0 && (
|
{plugins.length > 0 && (
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
@@ -396,6 +419,13 @@ export default function AdminPluginsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/plugins/${plugin.id}`}
|
||||||
|
title="Configure"
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Save, Loader2, Lock } from 'lucide-react';
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
// Feature gates managed on their own admin pages (excluded from this list)
|
// Feature gates managed on their own admin pages (excluded from this list)
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||||
@@ -19,6 +20,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
@@ -28,10 +30,12 @@ const RESTRICTABLE_SETTINGS = [
|
|||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
@@ -51,7 +55,7 @@ export default function AdminPolicyPage() {
|
|||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -103,7 +107,7 @@ export default function AdminPolicyPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setConfig(await res.json());
|
setConfig(await res.json());
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -112,6 +113,14 @@ export default function AdminSettingsPage() {
|
|||||||
<SettingsSection title="General">
|
<SettingsSection title="General">
|
||||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy({
|
setPolicy({
|
||||||
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
|
|||||||
async function fetchThemes() {
|
async function fetchThemes() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes');
|
const res = await apiFetch('/api/admin/themes');
|
||||||
if (res.ok) setThemes(await res.json());
|
if (res.ok) setThemes(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function toggleTheme(id: string, enabled: boolean) {
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of disabled) {
|
for (const t of disabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of enabled) {
|
for (const t of enabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
|
|||||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Stalwart error response to extract meaningful error message
|
||||||
|
*/
|
||||||
|
function parseStalwartError(responseText: string): string {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(responseText);
|
||||||
|
if (error.detail) return error.detail;
|
||||||
|
if (error.error) return error.error;
|
||||||
|
return `HTTP ${error.status || 'Error'}`;
|
||||||
|
} catch {
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/account/stalwart/auth
|
* GET /api/account/stalwart/auth
|
||||||
* Proxy to Stalwart GET /api/account/auth
|
* Proxy to Stalwart GET /api/account/auth
|
||||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
logger.warn('Stalwart auth info failed', { status: response.status });
|
const detail = parseStalwartError(text);
|
||||||
|
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch auth info', details: text },
|
{ error: detail || 'Failed to fetch auth info' },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Stalwart error response to extract meaningful error message
|
||||||
|
*/
|
||||||
|
function parseStalwartError(responseText: string): string {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(responseText);
|
||||||
|
if (error.detail) return error.detail;
|
||||||
|
if (error.error) return error.error;
|
||||||
|
return `HTTP ${error.status || 'Error'}`;
|
||||||
|
} catch {
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/account/stalwart/crypto
|
* GET /api/account/stalwart/crypto
|
||||||
* Proxy to Stalwart GET /api/account/crypto
|
* Proxy to Stalwart GET /api/account/crypto
|
||||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
logger.warn('Stalwart crypto info failed', { status: response.status });
|
const detail = parseStalwartError(text);
|
||||||
|
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch crypto info', details: text },
|
{ error: detail || 'Failed to fetch crypto info' },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession } from '@/lib/auth/crypto';
|
import { encryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If session cookie exists, update it with the new password
|
// If session cookie exists, update it with the new password
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
if (creds.hasSessionCookie) {
|
if (creds.hasSessionCookie) {
|
||||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||||
const cookieStore = await cookies();
|
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
||||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
}
|
||||||
|
|
||||||
|
if (creds.authHeader.startsWith('Basic ')) {
|
||||||
|
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
||||||
|
serverUrl: creds.serverUrl,
|
||||||
|
username: creds.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Stalwart error response to extract meaningful error message
|
||||||
|
*/
|
||||||
|
function parseStalwartError(responseText: string): string {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(responseText);
|
||||||
|
if (error.detail) return error.detail;
|
||||||
|
if (error.error) return error.error;
|
||||||
|
return `HTTP ${error.status || 'Error'}`;
|
||||||
|
} catch {
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/account/stalwart/principal
|
* GET /api/account/stalwart/principal
|
||||||
* Proxy to Stalwart GET /api/principal/{username}
|
* Proxy to Stalwart GET /api/principal/{username}
|
||||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
logger.warn('Stalwart principal fetch failed', { status: response.status });
|
const detail = parseStalwartError(text);
|
||||||
|
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch principal', details: text },
|
{ error: detail || 'Failed to fetch principal' },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/auth — Login
|
* POST /api/admin/auth - Login
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/auth — Check session status
|
* GET /api/admin/auth - Check session status
|
||||||
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/auth — Logout
|
* DELETE /api/admin/auth - Logout
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||||
*
|
*
|
||||||
* This endpoint is public (no admin auth) so browsers can load images.
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
* Only files in the branding directory are served; directory traversal is prevented.
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/branding — Upload a branding image file
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
*
|
*
|
||||||
* Expects JSON body: { slot: string }
|
* Expects JSON body: { slot: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/change-password — Change admin password
|
* POST /api/admin/change-password - Change admin password
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
|||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
* Proxies to the extension directory API
|
* Proxies to the extension directory API
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/marketplace — Install an extension from the directory
|
* POST /api/admin/marketplace - Install an extension from the directory
|
||||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const code = await jsFile.async('string');
|
const code = await jsFile.async('string');
|
||||||
|
|
||||||
|
// Block plugins with dangerous JS patterns
|
||||||
|
const DANGEROUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
const dangerousFindings: string[] = [];
|
||||||
|
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) dangerousFindings.push(label);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
if (dangerousFindings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate permissions
|
// Validate permissions
|
||||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
*
|
*
|
||||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
||||||
|
*
|
||||||
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getPluginConfig(id);
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string, value: unknown }
|
||||||
|
* Requires admin authentication (checked via admin session).
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string; value?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPluginConfig(id, body.key, body.value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePluginConfigKey(id, body.key);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins — List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +41,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const code = await entryFile.async('string');
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
// Security warnings (logged but not blocking for admin)
|
// Security: block plugins containing dangerous JS patterns
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
pattern.lastIndex = 0;
|
pattern.lastIndex = 0;
|
||||||
}
|
}
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
|
|||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin, warnings });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -172,7 +181,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -209,7 +218,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins — Remove a plugin
|
* DELETE /api/admin/plugins - Remove a plugin
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +22,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/stalwart-check
|
* GET /api/admin/stalwart-check
|
||||||
* Check if the currently logged-in user is a Stalwart admin.
|
* Check if the currently logged-in user is a Stalwart admin.
|
||||||
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
|||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes — List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +32,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/themes — Remove a theme
|
* DELETE /api/admin/themes - Remove a theme
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
|
|||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import {
|
||||||
|
clearStalwartAuthContextInStore,
|
||||||
|
setStalwartAuthContextInStore,
|
||||||
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
|
|||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
|
const { serverUrl, username } = credentials;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ serverUrl, username },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - retrieve full credentials (including password) for session restoration.
|
||||||
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
|
||||||
|
// Browsers set these automatically and they cannot be overridden by JS.
|
||||||
|
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
|
const secFetchMode = request.headers.get('sec-fetch-mode');
|
||||||
|
const secFetchDest = request.headers.get('sec-fetch-dest');
|
||||||
|
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = decryptSession(token);
|
||||||
|
if (!credentials) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(credentials, {
|
return NextResponse.json(credentials, {
|
||||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||||
});
|
});
|
||||||
@@ -77,10 +150,12 @@ export async function DELETE(request: NextRequest) {
|
|||||||
// Delete all session cookies (slots 0-4)
|
// Delete all session cookies (slots 0-4)
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
cookieStore.delete(sessionCookieName(i));
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, i);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const slot = getSlot(request);
|
const slot = getSlot(request);
|
||||||
cookieStore.delete(sessionCookieName(slot));
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.SESSION_SECRET) {
|
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
|
return bodySlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !authHeader) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request, bodySlot);
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
|
||||||
|
await setStalwartAuthContext(slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to store Stalwart auth context', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
|
*
|
||||||
|
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||||
|
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||||
|
*
|
||||||
|
* Tries three strategies:
|
||||||
|
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||||
|
* 2. ROPC grant without client_id
|
||||||
|
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function tryTokenRequest(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, tokens };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||||
|
// 1. Try OAuth discovery
|
||||||
|
const metadata = await discoverOAuth(serverUrl);
|
||||||
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
|
const candidates = [
|
||||||
|
`${serverUrl}/auth/token`,
|
||||||
|
`${serverUrl}/api/oauth/token`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||||
|
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||||
|
if (probe.status !== 404 && probe.status !== 405) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error - endpoint not reachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
|
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
||||||
|
// public URL the browser uses, e.g. inside Docker).
|
||||||
|
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
||||||
|
|
||||||
|
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
// Also try with the client-provided URL in case the internal one differs
|
||||||
|
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
||||||
|
if (!clientEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptAllStrategies(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Strategy 1: ROPC with client_id (if configured)
|
||||||
|
if (clientId) {
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||||
|
if (clientSecret) params.set('client_secret', clientSecret);
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: ROPC without client_id
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'token_exchange_failed',
|
||||||
|
detail: 'All token exchange strategies failed',
|
||||||
|
attempts,
|
||||||
|
}, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeAndRespond(
|
||||||
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in || 3600,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -33,8 +34,8 @@ export async function GET() {
|
|||||||
oauthOnly,
|
oauthOnly,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||||
stalwartFeaturesEnabled,
|
stalwartFeaturesEnabled,
|
||||||
devMode: configManager.get<boolean>('devMode', false),
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
@@ -47,6 +48,7 @@ export async function GET() {
|
|||||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+86
-21
@@ -1,9 +1,39 @@
|
|||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { BlockList, isIP } from 'node:net';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
function isValidExternalUrl(urlString: string): boolean {
|
const blockedAddressRanges = new BlockList();
|
||||||
|
blockedAddressRanges.addAddress('0.0.0.0');
|
||||||
|
blockedAddressRanges.addAddress('127.0.0.1');
|
||||||
|
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||||
|
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||||
|
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||||
|
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||||
|
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||||
|
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||||
|
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||||
|
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||||
|
|
||||||
|
function normalizeHostname(hostname: string): string {
|
||||||
|
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpAddress(hostname: string): boolean {
|
||||||
|
const normalized = normalizeHostname(hostname);
|
||||||
|
const family = isIP(normalized);
|
||||||
|
if (family === 4) {
|
||||||
|
return blockedAddressRanges.check(normalized, 'ipv4');
|
||||||
|
}
|
||||||
|
if (family === 6) {
|
||||||
|
return blockedAddressRanges.check(normalized, 'ipv6');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = new URL(urlString);
|
url = new URL(urlString);
|
||||||
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
const hostname = normalizeHostname(url.hostname);
|
||||||
|
|
||||||
// Block private/internal hostnames
|
// Block private/internal hostnames
|
||||||
if (
|
if (
|
||||||
hostname === 'localhost' ||
|
hostname === 'localhost' ||
|
||||||
hostname === '127.0.0.1' ||
|
hostname.endsWith('.localhost') ||
|
||||||
hostname === '::1' ||
|
|
||||||
hostname === '0.0.0.0' ||
|
|
||||||
hostname.endsWith('.local') ||
|
hostname.endsWith('.local') ||
|
||||||
hostname.endsWith('.internal') ||
|
hostname.endsWith('.internal') ||
|
||||||
hostname.endsWith('.arpa') ||
|
hostname.endsWith('.arpa') ||
|
||||||
hostname.startsWith('10.') ||
|
hostname.endsWith('.localdomain')
|
||||||
hostname.startsWith('192.168.') ||
|
|
||||||
hostname.startsWith('169.254.') ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
if (isBlockedIpAddress(hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIP(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
if (records.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidExternalUrl(url)) {
|
if (!(await isValidExternalUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const MAX_REDIRECTS = 5;
|
||||||
signal: controller.signal,
|
let currentUrl = url;
|
||||||
headers: {
|
let response: Response | undefined;
|
||||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
|
||||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
},
|
if (!(await isValidExternalUrl(currentUrl))) {
|
||||||
redirect: 'follow',
|
clearTimeout(timeout);
|
||||||
});
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||||
|
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||||
|
},
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
|
}
|
||||||
|
// Resolve relative redirects
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Remote server returned ${response.status}` },
|
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
*
|
*
|
||||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
* No admin auth required — this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
|
// Cache resized images in memory to avoid reprocessing on every request
|
||||||
|
const cache = new Map<number, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
// Absolute URL (http/https)
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path relative to public/ directory
|
||||||
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
|
return readFile(publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ size: string }> }
|
||||||
|
) {
|
||||||
|
const { size: sizeParam } = await params;
|
||||||
|
const size = parseInt(sizeParam, 10);
|
||||||
|
|
||||||
|
if (!VALID_SIZES.has(size)) {
|
||||||
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||||
|
if (!iconUrl) {
|
||||||
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(size)) {
|
||||||
|
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(size, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA icon:', err);
|
||||||
|
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-13
@@ -3,8 +3,10 @@ import { cookies } from 'next/headers';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
function classifyError(error: unknown): { message: string; status: number } {
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
@@ -47,33 +49,43 @@ function classifyError(error: unknown): { message: string; status: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify identity against session cookies across all account slots.
|
* Verify identity against session cookies across all account slots.
|
||||||
* With multi-account, the requesting account may be on any slot (0-4).
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns true if any slot matches OR if no session cookies exist at all.
|
* Checks both basic-auth session cookies and stalwart auth context cookies
|
||||||
|
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
||||||
|
* Returns true only if a matching cookie is found.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
let hasAnyCookie = false;
|
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||||
|
|
||||||
for (let slot = 0; slot <= 4; slot++) {
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
|
// Check basic-auth session cookie
|
||||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
if (!token) continue;
|
if (token) {
|
||||||
hasAnyCookie = true;
|
const session = decryptSession(token);
|
||||||
|
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = decryptSession(token);
|
// Check stalwart auth context cookie (set for all auth modes)
|
||||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
return true; // Found a matching slot
|
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
// No matching session found (or no cookies at all) → reject
|
||||||
if (!hasAnyCookie) return true;
|
|
||||||
|
|
||||||
// Cookies exist but none matched → identity mismatch
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce admin policy — strip locked settings so users can't override them
|
// Enforce admin policy - strip locked settings so users can't override them
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
const filteredSettings = { ...settings };
|
const filteredSettings = { ...settings };
|
||||||
|
|||||||
+42
-12
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
|
|
||||||
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
||||||
|
|
||||||
|
function normalizeDavRelativePath(rawPath: string): string {
|
||||||
|
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
|
||||||
|
const segments = sanitized.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
return segments.map((segment) => {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(segment);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid WebDAV path encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
|
||||||
|
throw new Error('Invalid WebDAV path segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeURIComponent(decoded);
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
|
||||||
|
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
|
||||||
|
const relativePath = normalizeDavRelativePath(rawPath);
|
||||||
|
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webdav
|
* POST /api/webdav
|
||||||
* Proxies WebDAV requests to the Stalwart server.
|
* Proxies WebDAV requests to the Stalwart server.
|
||||||
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||||
const cleanPath = davPath.replace(/^\/+/, '');
|
|
||||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||||
const targetUrl = cleanPath
|
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
|
|
||||||
// Build headers for the upstream request
|
// Build headers for the upstream request
|
||||||
const upstreamHeaders: Record<string, string> = {
|
const upstreamHeaders: Record<string, string> = {
|
||||||
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
|
|||||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||||
const destination = request.headers.get('X-WebDAV-Destination');
|
const destination = request.headers.get('X-WebDAV-Destination');
|
||||||
if (destination) {
|
if (destination) {
|
||||||
const cleanDest = destination.replace(/^\/+/, '');
|
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||||
upstreamHeaders['Destination'] = cleanDest
|
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overwrite = request.headers.get('Overwrite');
|
const overwrite = request.headers.get('Overwrite');
|
||||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||||
|
|
||||||
// Forward request body for methods that need it
|
// Forward request body for methods that need it.
|
||||||
let body: ArrayBuffer | null = null;
|
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||||
if (method === 'PROPFIND' || method === 'PUT') {
|
// PROPFIND bodies are small XML and are read fully.
|
||||||
|
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||||
|
if (method === 'PROPFIND') {
|
||||||
body = await request.arrayBuffer();
|
body = await request.arrayBuffer();
|
||||||
|
} else if (method === 'PUT') {
|
||||||
|
body = request.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: upstreamHeaders,
|
headers: upstreamHeaders,
|
||||||
body,
|
body,
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
});
|
// `duplex: 'half'` is required by undici when sending a streaming request body.
|
||||||
|
...(method === 'PUT' ? { duplex: 'half' } : {}),
|
||||||
|
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
|
||||||
|
|
||||||
// For file downloads (GET), stream the response back
|
// For file downloads (GET), stream the response back
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,6 +208,11 @@ body {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.email-content-text a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
|||||||
+19
-2
@@ -1,3 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- Generator: Gravit.io -->
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
|
<defs>
|
||||||
|
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||||
|
<style>
|
||||||
|
.icon-bg { fill: #ffffff; }
|
||||||
|
.icon-mark { fill: rgb(219,45,84); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.icon-bg { fill: #18181b; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||||
|
<rect width="1000" height="1000" class="icon-bg"/>
|
||||||
|
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -20,6 +22,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
description: "Minimalist webmail client using JMAP protocol",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -36,6 +46,14 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta
|
||||||
|
name="apple-mobile-web-app-title"
|
||||||
|
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
{parentOrigin && (
|
{parentOrigin && (
|
||||||
<meta name="parent-origin" content={parentOrigin} />
|
<meta name="parent-origin" content={parentOrigin} />
|
||||||
)}
|
)}
|
||||||
@@ -63,7 +81,9 @@ export default async function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
{children}
|
{children}
|
||||||
|
<PWAInstallPrompt />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
const appName =
|
||||||
|
process.env.APP_NAME ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
|
"Bulwark Webmail";
|
||||||
|
|
||||||
|
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||||
|
const description =
|
||||||
|
process.env.APP_DESCRIPTION ||
|
||||||
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
|
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||||
|
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||||
|
|
||||||
|
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||||
|
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||||
|
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||||
|
|
||||||
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
|
? [
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description,
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
theme_color: themeColor,
|
||||||
|
background_color: backgroundColor,
|
||||||
|
icons,
|
||||||
|
categories: ["productivity"],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||||
|
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo, useRef, useEffect, useCallback } from "react";
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { MapPin, Users } from "lucide-react";
|
import { MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
|
|||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) =>
|
const sorted = [...events].sort((a, b) =>
|
||||||
parseISO(a.start).getTime() - parseISO(b.start).getTime()
|
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups: DayGroup[] = [];
|
const groups: DayGroup[] = [];
|
||||||
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
|
|||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = parseISO(ev.start);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
const durMin = parseDuration(ev.duration);
|
||||||
const end = new Date(start.getTime() + durMin * 60000);
|
const end = getEventEndDate(ev);
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
|
|||||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -66,7 +66,7 @@ export function CalendarDayView({
|
|||||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||||
if (!spansThisDay) return;
|
if (!spansThisDay) return;
|
||||||
|
|
||||||
if (ev.showWithoutTime) allDay.push(ev);
|
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||||
else timed.push(ev);
|
else timed.push(ev);
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
@@ -127,10 +127,10 @@ export function CalendarDayView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||||
{isMobile
|
{isMobile
|
||||||
@@ -200,7 +200,7 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -241,11 +241,9 @@ export function CalendarDayView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ export function CalendarSidebarPanel({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -162,6 +165,9 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||||
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Subscription context menu on right-click */}
|
{/* Subscription context menu on right-click */}
|
||||||
@@ -173,6 +179,16 @@ export function CalendarSidebarPanel({
|
|||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
onEditSubscription?.(sub.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{tSub('edit')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -77,7 +77,7 @@ export function CalendarWeekView({
|
|||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
if (!ev.showWithoutTime) {
|
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||||
const arr = timed.get(key) || [];
|
const arr = timed.get(key) || [];
|
||||||
arr.push(ev);
|
arr.push(ev);
|
||||||
timed.set(key, arr);
|
timed.set(key, arr);
|
||||||
@@ -89,10 +89,18 @@ export function CalendarWeekView({
|
|||||||
return timed;
|
return timed;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
const allDaySegments = useMemo(() => {
|
||||||
events.filter((event) => event.showWithoutTime),
|
const explicitAllDay = buildWeekSegmentsRaw(
|
||||||
weekDays,
|
events.filter((event) => event.showWithoutTime),
|
||||||
), [events, weekDays]);
|
weekDays,
|
||||||
|
);
|
||||||
|
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||||
|
events.filter((event) => !event.showWithoutTime),
|
||||||
|
weekDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||||
|
}, [events, weekDays]);
|
||||||
|
|
||||||
const allDayRowCount = useMemo(() => {
|
const allDayRowCount = useMemo(() => {
|
||||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
@@ -189,13 +197,13 @@ export function CalendarWeekView({
|
|||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col flex-1",
|
"flex min-h-0 min-w-0 flex-col flex-1",
|
||||||
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
||||||
)}
|
)}
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label={t("views.week")}
|
aria-label={t("views.week")}
|
||||||
>
|
>
|
||||||
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
@@ -321,7 +329,7 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -344,7 +352,7 @@ export function CalendarWeekView({
|
|||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -371,11 +379,9 @@ export function CalendarWeekView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endTime = getEventEndDate(event);
|
||||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
|
|||||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
if (!event.alerts) return null;
|
if (!event.alerts) return null;
|
||||||
const first = Object.values(event.alerts)[0];
|
const first = Object.values(event.alerts)[0];
|
||||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
|||||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endDate = getEventEndDate(event);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
getStatusCounts,
|
getStatusCounts,
|
||||||
buildParticipantMap,
|
buildParticipantMap,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -159,7 +161,7 @@ export function EventModal({
|
|||||||
}, [event, existingParticipants]);
|
}, [event, existingParticipants]);
|
||||||
|
|
||||||
const getInitialStart = (): Date => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return getEventStartDate(event);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
if (defaultEndDate) return d;
|
if (defaultEndDate) return d;
|
||||||
@@ -177,9 +179,7 @@ export function EventModal({
|
|||||||
if (event.showWithoutTime) {
|
if (event.showWithoutTime) {
|
||||||
return getEventDisplayEndDate(event);
|
return getEventDisplayEndDate(event);
|
||||||
}
|
}
|
||||||
const s = parseISO(event.start);
|
return getEventEndDate(event);
|
||||||
const dur = parseDuration(event.duration);
|
|
||||||
return new Date(s.getTime() + dur * 60000);
|
|
||||||
}
|
}
|
||||||
if (defaultEndDate) return new Date(defaultEndDate);
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
@@ -190,6 +190,9 @@ export function EventModal({
|
|||||||
const [location, setLocation] = useState(
|
const [location, setLocation] = useState(
|
||||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||||
);
|
);
|
||||||
|
const [virtualLocation, setVirtualLocation] = useState(
|
||||||
|
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
|
||||||
|
);
|
||||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
@@ -315,6 +318,20 @@ export function EventModal({
|
|||||||
data.locations = null;
|
data.locations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (virtualLocation.trim()) {
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
uri: virtualLocation.trim(),
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
|
||||||
|
data.virtualLocations = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence !== "none") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
@@ -363,8 +380,10 @@ export function EventModal({
|
|||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
attendees
|
attendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
|
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||||
} else if (attendees.length === 0 && event?.participants) {
|
} else if (attendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
data.replyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||||
@@ -374,7 +393,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -384,11 +403,9 @@ export function EventModal({
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
const newUid = generateUUID();
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
uid: newUid,
|
uid: newUid,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@@ -403,6 +420,7 @@ export function EventModal({
|
|||||||
privacy: event.privacy,
|
privacy: event.privacy,
|
||||||
};
|
};
|
||||||
if (event.locations) data.locations = structuredClone(event.locations);
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
if (event.participants) data.participants = structuredClone(event.participants);
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
@@ -456,9 +474,9 @@ export function EventModal({
|
|||||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
@@ -567,9 +585,9 @@ export function EventModal({
|
|||||||
|
|
||||||
// View mode: read-only display of event details with Edit button
|
// View mode: read-only display of event details with Edit button
|
||||||
if (mode === "view" && event) {
|
if (mode === "view" && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
@@ -780,6 +798,39 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
{t("form.meeting_link")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={virtualLocation}
|
||||||
|
onChange={(e) => setVirtualLocation(e.target.value)}
|
||||||
|
placeholder="https://meet.example.com/..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<PluginSlot
|
||||||
|
name="calendar-event-actions"
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
extraProps={{
|
||||||
|
eventData: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startDate + 'T' + startTime,
|
||||||
|
end: endDate + 'T' + endTime,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
setVirtualLocation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">
|
<label className="text-sm font-medium mb-1 block">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
|||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -17,7 +19,7 @@ interface ICalImportModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||||
|
|
||||||
type ImportStep = "select" | "preview" | "importing";
|
type ImportStep = "select" | "preview" | "importing";
|
||||||
@@ -125,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
setIsParsing(true);
|
setIsParsing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/fetch-ical", {
|
const response = await apiFetch("/api/fetch-ical", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: trimmed }),
|
body: JSON.stringify({ url: trimmed }),
|
||||||
@@ -195,7 +197,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||||
if (!event.start) return "";
|
if (!event.start) return "";
|
||||||
try {
|
try {
|
||||||
const date = parseISO(event.start);
|
const date = getEventStartDate(event as CalendarEvent);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
return event.showWithoutTime
|
return event.showWithoutTime
|
||||||
? format(date, "MMM d, yyyy")
|
? format(date, "MMM d, yyyy")
|
||||||
|
|||||||
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: IJMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
editSubscription?: ICalSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||||
const t = useTranslations("calendar.subscription");
|
const t = useTranslations("calendar.subscription");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||||
|
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const isEdit = !!editSubscription;
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [color, setColor] = useState("#3b82f6");
|
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
const [name, setName] = useState(editSubscription?.name || "");
|
||||||
|
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
if (isEdit && editSubscription) {
|
||||||
if (subscription) {
|
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||||
toast.success(t("success", { name: name.trim() }));
|
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
|
||||||
|
if (name.trim() !== editSubscription.name) updates.name = name.trim();
|
||||||
|
if (color !== editSubscription.color) updates.color = color;
|
||||||
|
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
|
||||||
|
await updateICalSubscription(client, editSubscription.id, updates);
|
||||||
|
toast.success(t("updated", { name: name.trim() }));
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(t("error"));
|
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||||
|
if (subscription) {
|
||||||
|
toast.success(t("success", { name: name.trim() }));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(t("error"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("error"));
|
setError(isEdit ? t("update_error") : t("error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5 text-primary" />
|
<Globe className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
{t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("subscribe")
|
isEdit ? t("save") : t("subscribe")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||||
isSameDay, isSameMonth, isToday, format, parseISO,
|
isSameDay, isSameMonth, isToday, format,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
@@ -54,7 +55,14 @@ export function MiniCalendar({
|
|||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
events.forEach(e => {
|
events.forEach(e => {
|
||||||
try { set.add(format(parseISO(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
|
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 */ }
|
||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
try {
|
try {
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
let derBytes: ArrayBuffer | string | null = null;
|
||||||
if (key.uri.startsWith('data:')) {
|
if (key.uri.startsWith('data:')) {
|
||||||
// data URI — extract base64 content
|
// data URI - extract base64 content
|
||||||
const commaIdx = key.uri.indexOf(',');
|
const commaIdx = key.uri.indexOf(',');
|
||||||
if (commaIdx === -1) continue;
|
if (commaIdx === -1) continue;
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
const b64 = key.uri.substring(commaIdx + 1);
|
||||||
@@ -178,7 +178,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||||
{hasNickname && (
|
{hasNickname && (
|
||||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +278,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -371,7 +371,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{contact.speakToAs.pronouns && (() => {
|
{contact.speakToAs.pronouns && (() => {
|
||||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
return firstPronoun ? (
|
return firstPronoun ? (
|
||||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
if (contact?.addressBookIds) {
|
if (contact?.addressBookIds) {
|
||||||
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
|
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
|
||||||
// but we need the namespaced id to match addressBooks entries
|
// so we can use them directly to match addressBook entries
|
||||||
if (contact.isShared && contact.accountId) {
|
|
||||||
return `${contact.accountId}:${ids[0]}`;
|
|
||||||
}
|
|
||||||
return ids[0];
|
return ids[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name & Identity — full width */}
|
{/* Name & Identity - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
@@ -578,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setEmails(next);
|
setEmails(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -642,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setPhones(next);
|
setPhones(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -680,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Addresses — full width */}
|
{/* Addresses - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -701,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
value={addr.context}
|
value={addr.context}
|
||||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -836,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="masculine">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="feminine">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="other">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
@@ -869,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Notes — full width */}
|
{/* Notes - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -914,7 +911,6 @@ function CategoryComboBox({
|
|||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Parse current keywords from comma-separated string
|
// Parse current keywords from comma-separated string
|
||||||
@@ -949,17 +945,6 @@ function CategoryComboBox({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
}, [currentKeywords, onChange]);
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
@@ -973,7 +958,7 @@ function CategoryComboBox({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative">
|
<div className="relative">
|
||||||
{/* Keyword badges */}
|
{/* Keyword badges */}
|
||||||
{currentKeywords.length > 0 && (
|
{currentKeywords.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
@@ -1001,6 +986,7 @@ function CategoryComboBox({
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onBlur={() => setIsOpen(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||||
/>
|
/>
|
||||||
@@ -1008,7 +994,7 @@ function CategoryComboBox({
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||||
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
|
||||||
{suggestions.map(kw => (
|
{suggestions.map(kw => (
|
||||||
<button
|
<button
|
||||||
key={kw}
|
key={kw}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||||
{cEmail && cName && (
|
{cEmail && cName && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{name || email || "—"}
|
{name || email || "-"}
|
||||||
</div>
|
</div>
|
||||||
{density !== 'extra-compact' && email && name && (
|
{density !== 'extra-compact' && email && name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
|
|||||||
onDeleteGroup?: (groupId: string) => void;
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +61,15 @@ export function ContactsSidebar({
|
|||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
|
onRenameAddressBook,
|
||||||
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const router = useRouter();
|
||||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||||
|
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||||
|
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
@@ -246,19 +254,32 @@ export function ContactsSidebar({
|
|||||||
{/* My Address Books */}
|
{/* My Address Books */}
|
||||||
{personalBooks.length > 0 && (
|
{personalBooks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<div className="flex items-center px-3 py-1 group">
|
||||||
onClick={() => toggleSection("addressBooks")}
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
onClick={() => toggleSection("addressBooks")}
|
||||||
>
|
className="flex items-center gap-1 flex-1 text-left"
|
||||||
{collapsed.addressBooks ? (
|
>
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
{collapsed.addressBooks ? (
|
||||||
) : (
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
)}
|
||||||
{t("address_books.title")}
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
</span>
|
{t("address_books.title")}
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||||
|
title={t("address_books.manage")}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
@@ -267,6 +288,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +384,7 @@ export function ContactsSidebar({
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onSelect={() => onSelectCategory({ keyword })}
|
onSelect={() => onSelectCategory({ keyword })}
|
||||||
onDropContacts={onDropContactsToCategory}
|
onDropContacts={onDropContactsToCategory}
|
||||||
|
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -372,20 +395,33 @@ export function ContactsSidebar({
|
|||||||
{/* Shared accounts with address books */}
|
{/* Shared accounts with address books */}
|
||||||
{sharedBookGroups.map((group) => (
|
{sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
<button
|
<div className="flex items-center px-3 py-1 group">
|
||||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
>
|
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||||
{collapsed[`shared-${group.accountId}`] ? (
|
>
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
) : (
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
<Share2 className="w-3 h-3 text-muted-foreground" />
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||||
</span>
|
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||||
|
title={t("address_books.manage")}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
@@ -394,12 +430,53 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address book context menu */}
|
||||||
|
{bookContextMenu.data && onRenameAddressBook && (
|
||||||
|
<ContextMenu
|
||||||
|
ref={bookMenuRef}
|
||||||
|
isOpen={bookContextMenu.isOpen}
|
||||||
|
position={bookContextMenu.position}
|
||||||
|
onClose={closeBookContextMenu}
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("address_books.rename")}
|
||||||
|
onClick={() => {
|
||||||
|
const book = bookContextMenu.data!;
|
||||||
|
closeBookContextMenu();
|
||||||
|
onRenameAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keyword (category) context menu */}
|
||||||
|
{keywordContextMenu.data && onRenameKeyword && (
|
||||||
|
<ContextMenu
|
||||||
|
ref={keywordMenuRef}
|
||||||
|
isOpen={keywordContextMenu.isOpen}
|
||||||
|
position={keywordContextMenu.position}
|
||||||
|
onClose={closeKeywordContextMenu}
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("rename_category")}
|
||||||
|
onClick={() => {
|
||||||
|
const kw = keywordContextMenu.data!;
|
||||||
|
closeKeywordContextMenu();
|
||||||
|
onRenameKeyword(kw);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Group context menu */}
|
{/* Group context menu */}
|
||||||
{groupContextMenu.data && (
|
{groupContextMenu.data && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -438,12 +515,14 @@ function CategoryItem({
|
|||||||
isActive,
|
isActive,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
count: number;
|
count: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
@@ -476,6 +555,7 @@ function CategoryItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -503,12 +583,14 @@ function AddressBookItem({
|
|||||||
contactCount,
|
contactCount,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
book: AddressBook;
|
book: AddressBook;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
contactCount: number;
|
contactCount: number;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
@@ -541,6 +623,7 @@ function AddressBookItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
|||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
showPreview: false,
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
});
|
});
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
selectedEmailIds: new Set<string>(),
|
selectedEmailIds: new Set<string>(),
|
||||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
|||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| null;
|
|| null;
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||||
// Called after updating the local calendar event. Best-effort — if it
|
// Called after updating the local calendar event. Best-effort - if it
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
// fails we still report the RSVP as sent since the calendar was updated.
|
||||||
const sendImipReply = async () => {
|
const sendImipReply = async () => {
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||||
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 py-3 space-y-2.5">
|
<div className="px-4 py-3 space-y-2.5">
|
||||||
{/* Event title */}
|
<div className="lg:flex lg:gap-6">
|
||||||
{summary?.title && (
|
{/* Left: Event info */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="lg:flex-1 space-y-2.5 min-w-0">
|
||||||
<h3 className={cn(
|
{/* Event title */}
|
||||||
"text-base font-semibold leading-snug",
|
{summary?.title && (
|
||||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
<div className="flex items-start justify-between gap-2">
|
||||||
)}>
|
<h3 className={cn(
|
||||||
{summary.title}
|
"text-base font-semibold leading-snug",
|
||||||
</h3>
|
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||||
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
)}>
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
{summary.title}
|
||||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
</h3>
|
||||||
</span>
|
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||||
|
{t('event_updated', { sequence: parsedEvent.sequence })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Event details */}
|
{/* Event details */}
|
||||||
<div className="space-y-1">
|
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
|
||||||
{summary?.start && (
|
{summary?.start && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{formatDateTime(summary.start)}
|
{formatDateTime(summary.start)}
|
||||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary?.location && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span>{summary.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary?.organizer && (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
{t('organizer', { name: summary.organizer })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary && summary.attendeeCount > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{summary?.location && (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
{/* Right: Info & actor messages on large screens */}
|
||||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
|
||||||
<span>{summary.location}</span>
|
{bannerInfo && (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
|
||||||
{summary?.organizer && (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
{t('organizer', { name: summary.organizer })}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{summary && summary.attendeeCount > 0 && (
|
{actorMessage && (
|
||||||
<span>{t('attendees', { count: summary.attendeeCount })}</span>
|
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
||||||
|
)}
|
||||||
|
{actorSummary?.participationComment && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
{t('actor_note', { comment: actorSummary.participationComment })}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info text */}
|
|
||||||
{bannerInfo && (
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actor message */}
|
|
||||||
{actorMessage && (
|
|
||||||
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actor comment */}
|
|
||||||
{actorSummary?.participationComment && (
|
|
||||||
<p className="text-xs text-muted-foreground italic">
|
|
||||||
{t('actor_note', { comment: actorSummary.participationComment })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Proposed changes */}
|
{/* Proposed changes */}
|
||||||
{proposedChanges.length > 0 && (
|
{proposedChanges.length > 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
|||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
@@ -29,13 +30,13 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
function htmlToPlainText(html: string): string {
|
function htmlToPlainText(html: string): string {
|
||||||
const tmp = document.createElement('div');
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
tmp.innerHTML = html;
|
return doc.body.textContent || '';
|
||||||
return tmp.textContent || tmp.innerText || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComposerDraftData {
|
export interface ComposerDraftData {
|
||||||
@@ -76,12 +77,15 @@ interface EmailComposerProps {
|
|||||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
from?: { email?: string; name?: string }[];
|
from?: { email?: string; name?: string }[];
|
||||||
|
replyToAddresses?: { email?: string; name?: string }[];
|
||||||
to?: { email?: string; name?: string }[];
|
to?: { email?: string; name?: string }[];
|
||||||
cc?: { email?: string; name?: string }[];
|
cc?: { email?: string; name?: string }[];
|
||||||
|
bcc?: { email?: string; name?: string }[];
|
||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
receivedAt?: string;
|
receivedAt?: string;
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,17 +104,22 @@ export function EmailComposer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
if (!replyTo) return "";
|
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(r => r.email).join(", ")
|
||||||
|
: replyTo.from?.[0]?.email || "";
|
||||||
if (mode === 'reply') {
|
if (mode === 'reply') {
|
||||||
const email = replyTo.from?.[0]?.email || "";
|
return replyTarget ? replyTarget + ', ' : "";
|
||||||
return email ? email + ', ' : "";
|
|
||||||
} else if (mode === 'replyAll') {
|
} else if (mode === 'replyAll') {
|
||||||
const from = replyTo.from?.[0]?.email || "";
|
|
||||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||||
const combined = [from, originalTo].filter(Boolean).join(", ");
|
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||||
return combined ? combined + ', ' : "";
|
return combined ? combined + ', ' : "";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -207,6 +216,8 @@ export function EmailComposer({
|
|||||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||||
|
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||||
|
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||||
|
|
||||||
const saveTemplateModalRef = useFocusTrap({
|
const saveTemplateModalRef = useFocusTrap({
|
||||||
isActive: showSaveAsTemplate,
|
isActive: showSaveAsTemplate,
|
||||||
@@ -220,12 +231,59 @@ export function EmailComposer({
|
|||||||
restoreFocus: true,
|
restoreFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const attachmentWarningRef = useFocusTrap({
|
||||||
|
isActive: showAttachmentWarning,
|
||||||
|
onEscape: () => setShowAttachmentWarning(false),
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectReplyIdentity) return;
|
||||||
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
|
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||||
|
to: replyTo?.to,
|
||||||
|
cc: replyTo?.cc,
|
||||||
|
bcc: replyTo?.bcc,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedIdentityId) {
|
||||||
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match identity by the account's email when replying from unified view
|
||||||
|
if (replyTo?.accountId) {
|
||||||
|
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
|
||||||
|
if (account?.email) {
|
||||||
|
const accountEmail = account.email.trim().toLowerCase();
|
||||||
|
const accountIdentity = identities.find(
|
||||||
|
(identity) => identity.email.trim().toLowerCase() === accountEmail
|
||||||
|
);
|
||||||
|
if (accountIdentity) {
|
||||||
|
setSelectedIdentityId(accountIdentity.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
autoSelectReplyIdentity,
|
||||||
|
identities,
|
||||||
|
initialData?.selectedIdentityId,
|
||||||
|
mode,
|
||||||
|
replyTo?.accountId,
|
||||||
|
replyTo?.bcc,
|
||||||
|
replyTo?.cc,
|
||||||
|
replyTo?.to,
|
||||||
|
selectedIdentityId,
|
||||||
|
]);
|
||||||
|
|
||||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||||
: currentIdentity?.textSignature
|
: currentIdentity?.textSignature
|
||||||
@@ -301,6 +359,17 @@ export function EmailComposer({
|
|||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-focus the To field when composing a new email or forwarding
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'forward' || mode === 'compose') {
|
||||||
|
// Small delay to ensure the input is rendered
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
toInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||||
@@ -308,10 +377,26 @@ export function EmailComposer({
|
|||||||
const toInputRef = useRef<HTMLInputElement>(null);
|
const toInputRef = useRef<HTMLInputElement>(null);
|
||||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bccInputRef = useRef<HTMLInputElement>(null);
|
const bccInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const subjectInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const toDropdownRef = useRef<HTMLDivElement>(null);
|
const toDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const focusSubject = useCallback(() => {
|
||||||
|
subjectInputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const focusBody = useCallback(() => {
|
||||||
|
if (plainTextMode) {
|
||||||
|
bodyRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
|
||||||
|
proseMirror?.focus();
|
||||||
|
}
|
||||||
|
}, [plainTextMode]);
|
||||||
|
|
||||||
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
||||||
if (autocompleteTimeoutRef.current) {
|
if (autocompleteTimeoutRef.current) {
|
||||||
clearTimeout(autocompleteTimeoutRef.current);
|
clearTimeout(autocompleteTimeoutRef.current);
|
||||||
@@ -475,17 +560,18 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||||
if (!client) return null;
|
return new Promise((resolve) => {
|
||||||
try {
|
const reader = new FileReader();
|
||||||
const { blobId } = await client.uploadBlob(file);
|
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
reader.onerror = () => {
|
||||||
} catch (error) {
|
debug.error(`Failed to read inline image ${file.name}`);
|
||||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
toast.error(t('upload_failed', { filename: file.name }));
|
||||||
toast.error(t('upload_failed', { filename: file.name }));
|
resolve(null);
|
||||||
return null;
|
};
|
||||||
}
|
reader.readAsDataURL(file);
|
||||||
}, [client, t]);
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
@@ -665,7 +751,7 @@ export function EmailComposer({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async (skipAttachmentCheck = false) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -684,6 +770,21 @@ export function EmailComposer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment reminder check
|
||||||
|
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||||
|
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
|
||||||
|
if (!hasAttachments) {
|
||||||
|
const bodyText = htmlToPlainText(body);
|
||||||
|
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||||
|
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||||
|
if (matched) {
|
||||||
|
setAttachmentWarningKeyword(matched);
|
||||||
|
setShowAttachmentWarning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let finalDraftId = draftId;
|
let finalDraftId = draftId;
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -944,7 +1045,7 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
{/* Mobile: send button in header */}
|
{/* Mobile: send button in header */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title={getSendTooltip()}
|
title={getSendTooltip()}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1040,6 +1141,7 @@ export function EmailComposer({
|
|||||||
onInsertAutocomplete={insertAutocomplete}
|
onInsertAutocomplete={insertAutocomplete}
|
||||||
validationError={validationErrors.to}
|
validationError={validationErrors.to}
|
||||||
validationMessage={t('validation.recipient_required')}
|
validationMessage={t('validation.recipient_required')}
|
||||||
|
onTab={focusSubject}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-0.5 shrink-0">
|
<div className="flex gap-0.5 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -1109,6 +1211,7 @@ export function EmailComposer({
|
|||||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||||
<Input
|
<Input
|
||||||
|
ref={subjectInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('subject_placeholder')}
|
placeholder={t('subject_placeholder')}
|
||||||
value={subject}
|
value={subject}
|
||||||
@@ -1116,6 +1219,12 @@ export function EmailComposer({
|
|||||||
setSubject(e.target.value);
|
setSubject(e.target.value);
|
||||||
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
focusBody();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
||||||
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
||||||
@@ -1128,6 +1237,7 @@ export function EmailComposer({
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={bodyRef}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBody(e.target.value);
|
setBody(e.target.value);
|
||||||
@@ -1142,16 +1252,18 @@ export function EmailComposer({
|
|||||||
aria-invalid={validationErrors.body || undefined}
|
aria-invalid={validationErrors.body || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RichTextEditor
|
<div ref={editorContainerRef}>
|
||||||
content={body}
|
<RichTextEditor
|
||||||
onChange={(html) => {
|
content={body}
|
||||||
setBody(html);
|
onChange={(html) => {
|
||||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
setBody(html);
|
||||||
}}
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
onImageUpload={handleImageUpload}
|
}}
|
||||||
placeholder={t('body_placeholder')}
|
onImageUpload={handleImageUpload}
|
||||||
hasError={validationErrors.body}
|
placeholder={t('body_placeholder')}
|
||||||
/>
|
hasError={validationErrors.body}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
@@ -1298,7 +1410,7 @@ export function EmailComposer({
|
|||||||
{t('discard')}
|
{t('discard')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title={getSendTooltip()}
|
title={getSendTooltip()}
|
||||||
className="hidden md:inline-flex"
|
className="hidden md:inline-flex"
|
||||||
@@ -1398,6 +1510,36 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAttachmentWarning && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||||
|
onClick={() => setShowAttachmentWarning(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={attachmentWarningRef}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||||
|
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||||
|
{t('forgot_attachment.back')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
||||||
|
{t('forgot_attachment.send_anyway')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCloseDialog && (
|
{showCloseDialog && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||||
@@ -1483,6 +1625,7 @@ function RecipientChipInput({
|
|||||||
onInsertAutocomplete,
|
onInsertAutocomplete,
|
||||||
validationError,
|
validationError,
|
||||||
validationMessage,
|
validationMessage,
|
||||||
|
onTab,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -1499,6 +1642,7 @@ function RecipientChipInput({
|
|||||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
||||||
validationError?: boolean;
|
validationError?: boolean;
|
||||||
validationMessage?: string;
|
validationMessage?: string;
|
||||||
|
onTab?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
const hasTrailingComma = value.trimEnd().endsWith(',');
|
const hasTrailingComma = value.trimEnd().endsWith(',');
|
||||||
@@ -1532,7 +1676,18 @@ function RecipientChipInput({
|
|||||||
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
||||||
if (e.key !== 'Tab') e.preventDefault();
|
if (e.key !== 'Tab') e.preventDefault();
|
||||||
commitCurrentInput();
|
commitCurrentInput();
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
if (e.key === 'Tab' && onTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTimeout(() => onTab(), 0);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey && onTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
onTab();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
// Get all active label/color tag IDs from email keywords
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.startsWith("$label:")
|
tags.push(
|
||||||
? key.slice("$label:".length)
|
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||||
: key.slice("$color:".length);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmailContextMenu({
|
export function EmailContextMenu({
|
||||||
@@ -137,7 +138,7 @@ export function EmailContextMenu({
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColor = getCurrentColor(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
|
||||||
@@ -306,24 +307,27 @@ export function EmailContextMenu({
|
|||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
role="menuitem"
|
<button
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
key={option.value}
|
||||||
className={cn(
|
role="menuitem"
|
||||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left 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>
|
>
|
||||||
{currentColor === option.value && (
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
<span className="flex-1">{option.name}</span>
|
||||||
)}
|
{isActive && (
|
||||||
</button>
|
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||||
))}
|
)}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
interface EmailHoverActionsProps {
|
interface EmailHoverActionsProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
|
backgroundClassName?: string;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
|
|||||||
|
|
||||||
export function EmailHoverActions({
|
export function EmailHoverActions({
|
||||||
email,
|
email,
|
||||||
|
backgroundClassName = "bg-muted",
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -77,6 +79,7 @@ export function EmailHoverActions({
|
|||||||
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const hoverBackgroundClassName = backgroundClassName;
|
||||||
|
|
||||||
if (hoverActions.length === 0) return null;
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ export function EmailHoverActions({
|
|||||||
onToggleStar?.();
|
onToggleStar?.();
|
||||||
break;
|
break;
|
||||||
case "markRead":
|
case "markRead":
|
||||||
onMarkAsRead?.(!isUnread);
|
onMarkAsRead?.(isUnread);
|
||||||
break;
|
break;
|
||||||
case "archive":
|
case "archive":
|
||||||
onArchive?.();
|
onArchive?.();
|
||||||
@@ -144,7 +147,10 @@ export function EmailHoverActions({
|
|||||||
CORNER_CLASSES[hoverActionsCorner],
|
CORNER_CLASSES[hoverActionsCorner],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-0.5 bg-muted rounded-lg px-1.5 py-0.5 shadow-md border border-border">
|
<div className={cn(
|
||||||
|
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
|
||||||
|
hoverBackgroundClassName,
|
||||||
|
)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +161,14 @@ export function EmailHoverActions({
|
|||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
|
<div
|
||||||
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
maskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
|||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { EmailHoverActions } from "./email-hover-actions";
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -32,9 +32,10 @@ interface EmailListItemProps {
|
|||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -43,11 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||||
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||||
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagIds = getEmailColorTags(email.keywords);
|
||||||
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
|
// Use first tag for background coloring
|
||||||
|
const keywordDef = keywordDefs[0] ?? null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality
|
||||||
@@ -114,15 +122,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -145,7 +157,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -156,85 +168,133 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* First Line: Sender and Date */}
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
? "font-bold text-foreground"
|
)}>
|
||||||
: "font-medium text-muted-foreground"
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
)}>
|
</span>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
</span>
|
<span className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
'shrink-0 truncate',
|
||||||
{isStarred && (
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
)}>
|
||||||
)}
|
{email.subject || t('no_subject')}
|
||||||
{isImportant && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
|
||||||
Important
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
{isAnswered && !isForwarded && (
|
)}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isForwarded && !isAnswered && (
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
{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 && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
{/* First Line: Sender and Date */}
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
)}>
|
<span className={cn(
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
"truncate text-sm",
|
||||||
{keywordDef.label}
|
isUnread
|
||||||
</span>
|
? "font-bold text-foreground"
|
||||||
)}
|
: "font-medium text-muted-foreground"
|
||||||
<span className={cn(
|
)}>
|
||||||
"text-xs tabular-nums",
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{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
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || t('no_subject')}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
)}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
</>
|
||||||
<p className={cn(
|
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +302,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -103,11 +103,16 @@ export function EmailList({
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const estimateSize = useCallback(() => {
|
const estimateSize = useCallback(() => {
|
||||||
|
if (isFocusedMailLayout) {
|
||||||
|
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||||
|
}
|
||||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: threadGroups.length,
|
count: threadGroups.length,
|
||||||
@@ -171,7 +176,7 @@ export function EmailList({
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await batchDelete(client);
|
await batchDelete(client, isInTrash);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setIsProcessing(false), 500);
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
}
|
}
|
||||||
@@ -216,15 +221,26 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||||
|
|
||||||
// Range-based load more: trigger when last visible item is near the end
|
// Range-based load more: trigger when last visible item is near the end.
|
||||||
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
|
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||||
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastVirtualItemIndex === undefined) return;
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||||
handleLoadMore();
|
// Clear any pending timer so we don't stack calls
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
loadMoreTimerRef.current = setTimeout(() => {
|
||||||
|
handleLoadMore();
|
||||||
|
loadMoreTimerRef.current = null;
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
};
|
||||||
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
// Scroll to the thread group containing the selected email
|
// Scroll to the thread group containing the selected email
|
||||||
@@ -244,7 +260,7 @@ export function EmailList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
|
|||||||
+272
-143
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
|
|||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils";
|
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||||
import {
|
import {
|
||||||
Reply,
|
Reply,
|
||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
|
PenSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
@@ -84,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
|||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||||
@@ -121,6 +122,7 @@ interface EmailViewerProps {
|
|||||||
onNavigatePrev?: () => void;
|
onNavigatePrev?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCompose?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
currentMailboxRole?: string;
|
currentMailboxRole?: string;
|
||||||
@@ -191,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
|||||||
return 'Attachment';
|
return 'Attachment';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.startsWith("$label:")
|
tags.push(
|
||||||
? key.slice("$label:".length)
|
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||||
: key.slice("$color:".length);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format recipients with contextual display
|
// Helper function to format recipients with contextual display
|
||||||
@@ -737,7 +740,7 @@ function ContactSidebarPanel({
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -865,6 +868,7 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
currentMailboxRole,
|
currentMailboxRole,
|
||||||
@@ -884,11 +888,16 @@ export function EmailViewer({
|
|||||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
// Detect if current mailbox is Junk folder
|
// Detect if current mailbox is Junk folder
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
@@ -906,7 +915,7 @@ export function EmailViewer({
|
|||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
@@ -928,7 +937,8 @@ export function EmailViewer({
|
|||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||||
const currentColor = getCurrentColor(email?.keywords);
|
const currentColors = getCurrentColors(email?.keywords);
|
||||||
|
const currentColor = currentColors[0] ?? null;
|
||||||
|
|
||||||
// S/MIME state
|
// S/MIME state
|
||||||
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
||||||
@@ -960,9 +970,9 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Ensure S/MIME key records are loaded from IndexedDB
|
// Ensure S/MIME key records are loaded from IndexedDB
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
smimeStore.load();
|
smimeStore.load(activeAccountId ?? undefined);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [activeAccountId]);
|
||||||
|
|
||||||
// Build mailbox tree for move-to dropdown
|
// Build mailbox tree for move-to dropdown
|
||||||
const moveTargetIds = useMemo(() => new Set(
|
const moveTargetIds = useMemo(() => new Set(
|
||||||
@@ -1147,9 +1157,27 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const autoMarkedEmailRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Reset auto-mark tracking when email changes
|
||||||
|
useEffect(() => {
|
||||||
|
autoMarkedEmailRef.current = null;
|
||||||
|
}, [email?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Mark as read when email is viewed, respecting the delay setting
|
// Mark as read when email is viewed, respecting the delay setting
|
||||||
if (!email || email.keywords?.$seen || !onMarkAsRead) {
|
if (!email || !onMarkAsRead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already read - record that so manual unread toggle won't re-trigger auto-mark
|
||||||
|
if (email.keywords?.$seen) {
|
||||||
|
autoMarkedEmailRef.current = email.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
|
||||||
|
if (autoMarkedEmailRef.current === email.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1162,17 +1190,18 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Instant mark
|
// Instant mark
|
||||||
if (markAsReadDelay === 0) {
|
if (markAsReadDelay === 0) {
|
||||||
|
autoMarkedEmailRef.current = email.id;
|
||||||
onMarkAsRead(email.id, true);
|
onMarkAsRead(email.id, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delayed mark
|
// Delayed mark
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
autoMarkedEmailRef.current = email.id;
|
||||||
onMarkAsRead(email.id, true);
|
onMarkAsRead(email.id, true);
|
||||||
}, markAsReadDelay);
|
}, markAsReadDelay);
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
|
|
||||||
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
||||||
|
|
||||||
// Reset external content permission and quick reply when email changes
|
// Reset external content permission and quick reply when email changes
|
||||||
@@ -1540,8 +1569,25 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (detection.type === 'enveloped-data') {
|
if (detection.type === 'enveloped-data') {
|
||||||
// Encrypted message
|
// Encrypted message
|
||||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
smimeDebug('[S/MIME] decrypt attempt:', {
|
||||||
|
keyRecordCount: keyRecords.length,
|
||||||
|
unlockedKeyCount: unlockedDecryptionKeys.size,
|
||||||
|
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
|
||||||
|
keyRecordIds: keyRecords.map(k => k.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short-circuit: no keys imported at all
|
||||||
|
if (keyRecords.length === 0) {
|
||||||
|
smimeDebug('[S/MIME] no key records available, skipping decrypt');
|
||||||
|
setSmimeStatus({
|
||||||
|
isSigned: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
decryptionError: 'no-key',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -1552,6 +1598,7 @@ export function EmailViewer({
|
|||||||
cmsBytes: candidate.normalized,
|
cmsBytes: candidate.normalized,
|
||||||
keyRecords,
|
keyRecords,
|
||||||
unlockedKeys: unlockedDecryptionKeys,
|
unlockedKeys: unlockedDecryptionKeys,
|
||||||
|
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||||
});
|
});
|
||||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
@@ -1597,7 +1644,7 @@ export function EmailViewer({
|
|||||||
// Check if inner content is also signed
|
// Check if inner content is also signed
|
||||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||||
if (nestedSignedData) {
|
if (nestedSignedData) {
|
||||||
// Nested sign-then-encrypt — verify inner signature
|
// Nested sign-then-encrypt - verify inner signature
|
||||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||||
source: nestedSignedData.source,
|
source: nestedSignedData.source,
|
||||||
@@ -1633,8 +1680,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
smimeError('[S/MIME] nested signature verify failed:', {
|
smimeError('[S/MIME] nested signature verify failed:', {
|
||||||
@@ -1672,10 +1726,12 @@ export function EmailViewer({
|
|||||||
decryptionError: 'locked',
|
decryptionError: 'locked',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||||
|
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||||
setSmimeStatus({
|
setSmimeStatus({
|
||||||
isSigned: false,
|
isSigned: false,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1737,8 +1793,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (result.status.signatureValid && result.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1771,6 +1834,7 @@ export function EmailViewer({
|
|||||||
smimeStore.autoImportSignerCerts,
|
smimeStore.autoImportSignerCerts,
|
||||||
smimeStore.keyRecords,
|
smimeStore.keyRecords,
|
||||||
smimeStore.unlockedDecryptionKeys,
|
smimeStore.unlockedDecryptionKeys,
|
||||||
|
smimeStore.unlockedLegacyDecryptionKeys,
|
||||||
smimeStore,
|
smimeStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1780,61 +1844,61 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||||
if (!tnefAtt?.blobId) {
|
if (!tnefAtt?.blobId) {
|
||||||
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
|
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.group('TNEF Processing');
|
debug.group('TNEF Processing', 'email');
|
||||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
||||||
hasRealHtmlBody = false;
|
hasRealHtmlBody = false;
|
||||||
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
||||||
}
|
}
|
||||||
if (hasRealHtmlBody) {
|
if (hasRealHtmlBody) {
|
||||||
debug.log('TNEF: Email has real HTML body, will extract attachments only');
|
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
|
||||||
} else {
|
} else {
|
||||||
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function processTnef() {
|
async function processTnef() {
|
||||||
try {
|
try {
|
||||||
debug.time('TNEF fetch blob');
|
debug.time('TNEF fetch blob', 'email');
|
||||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||||
debug.timeEnd('TNEF fetch blob');
|
debug.timeEnd('TNEF fetch blob', 'email');
|
||||||
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
debug.log('TNEF: Processing cancelled after fetch');
|
debug.log('email', 'TNEF: Processing cancelled after fetch');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
|
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tnefData = new Uint8Array(blobBytes);
|
const tnefData = new Uint8Array(blobBytes);
|
||||||
debug.time('TNEF parse');
|
debug.time('TNEF parse', 'email');
|
||||||
const parsed = parseTnef(tnefData);
|
const parsed = parseTnef(tnefData);
|
||||||
debug.timeEnd('TNEF parse');
|
debug.timeEnd('TNEF parse', 'email');
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
debug.log('TNEF: Processing cancelled after parse');
|
debug.log('email', 'TNEF: Processing cancelled after parse');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1844,11 +1908,11 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments.length > 0) {
|
if (parsed.attachments.length > 0) {
|
||||||
setTnefAttachments(parsed.attachments);
|
setTnefAttachments(parsed.attachments);
|
||||||
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1886,13 +1950,13 @@ export function EmailViewer({
|
|||||||
const hasRealText = !!textValue;
|
const hasRealText = !!textValue;
|
||||||
|
|
||||||
if (hasRealHtml || hasRealText) {
|
if (hasRealHtml || hasRealText) {
|
||||||
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
|
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.group('Embedded RFC822 Unwrapping');
|
debug.group('Embedded RFC822 Unwrapping', 'email');
|
||||||
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||||
debug.log('Outer email body is empty, will unwrap embedded email');
|
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -1901,7 +1965,7 @@ export function EmailViewer({
|
|||||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('Embedded RFC822: Fetched blob is empty');
|
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1911,7 +1975,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -1923,7 +1987,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||||
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
||||||
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
|
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
|
||||||
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
||||||
).join(', '));
|
).join(', '));
|
||||||
}
|
}
|
||||||
@@ -2020,11 +2084,15 @@ export function EmailViewer({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
|
||||||
const jmapAttachments = (email?.attachments ?? [])
|
const jmapAttachments = (email?.attachments ?? [])
|
||||||
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
||||||
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
||||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||||
|
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||||
|
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||||
|
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||||
.map((attachment, index) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
name: attachment.name || null,
|
name: attachment.name || null,
|
||||||
@@ -2055,7 +2123,7 @@ export function EmailViewer({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
|
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
@@ -2250,9 +2318,11 @@ export function EmailViewer({
|
|||||||
// Use shared sanitization config as base (more secure)
|
// Use shared sanitization config as base (more secure)
|
||||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||||
|
|
||||||
// Check if sender is trusted
|
// Check if sender is trusted (localStorage list or address book)
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
const senderIsTrusted = senderEmail
|
||||||
|
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||||
|
: false;
|
||||||
|
|
||||||
// Block external content based on policy:
|
// Block external content based on policy:
|
||||||
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
||||||
@@ -2359,7 +2429,7 @@ export function EmailViewer({
|
|||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
|
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -2526,14 +2596,21 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// If email has native dark mode, let it handle its own theming
|
// If email has native dark mode, let it handle its own theming
|
||||||
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
||||||
|
// Re-invert leaf media elements so they appear normal.
|
||||||
|
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
||||||
|
// double re-inverting images nested inside those containers.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
html { background: #1a1a1a; }
|
html { background: #1a1a1a; }
|
||||||
body { filter: invert(1) hue-rotate(180deg); }
|
body { filter: invert(1) hue-rotate(180deg); }
|
||||||
img, video, picture, svg, canvas, object, embed,
|
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||||
[style*="background-image"], [style*="background:"],
|
filter: invert(1) hue-rotate(180deg);
|
||||||
[background], [bgcolor],
|
}
|
||||||
td[background], table[background],
|
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
img[src], input[type="image"] {
|
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
td[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
` : '';
|
` : '';
|
||||||
@@ -2572,11 +2649,35 @@ export function EmailViewer({
|
|||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
a.setAttribute('rel', 'noopener noreferrer');
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||||
|
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||||
|
if (isDark && !emailHasNativeDarkMode) {
|
||||||
|
const win = doc.defaultView;
|
||||||
|
if (win) {
|
||||||
|
doc.body.querySelectorAll('*').forEach(el => {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
// Skip elements already handled by CSS attribute selectors
|
||||||
|
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
|
||||||
|
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
|
||||||
|
// Skip leaf media elements (already re-inverted by CSS)
|
||||||
|
const tag = htmlEl.tagName;
|
||||||
|
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
|
||||||
|
const computed = win.getComputedStyle(htmlEl);
|
||||||
|
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||||
|
// Only re-invert if this container doesn't have media children
|
||||||
|
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
|
||||||
|
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
// Export email as .eml file
|
// Export email as .eml file
|
||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
@@ -2775,6 +2876,12 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||||
|
{onCompose && (
|
||||||
|
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||||
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
|
{t('compose')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2790,7 +2897,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||||
{showBackButton && isTablet && !tabletListVisible && onBack && (
|
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2805,7 +2912,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||||
title={t('tooltips.edit_draft')}
|
title={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
@@ -2854,7 +2961,7 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="toolbar-actions" />
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||||
@@ -2951,7 +3058,7 @@ export function EmailViewer({
|
|||||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5" />
|
||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
@@ -2959,43 +3066,51 @@ export function EmailViewer({
|
|||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||||
currentColor && "bg-muted/50"
|
currentColors.length > 0 && "bg-muted/50"
|
||||||
)}
|
)}
|
||||||
title={t('set_color')}
|
title={t('set_color')}
|
||||||
>
|
>
|
||||||
{(() => {
|
{currentColors.length > 0 ? (
|
||||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
<>
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
<span className="flex items-center gap-0.5">
|
||||||
return dotClass ? (
|
{currentColors.slice(0, 3).map((tagId) => {
|
||||||
<>
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
|
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||||
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
|
})}
|
||||||
</>
|
</span>
|
||||||
) : (
|
{showToolbarLabels && currentColors.length === 1 && (
|
||||||
<>
|
<span className="text-xs font-medium text-foreground">
|
||||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
</span>
|
||||||
</>
|
)}
|
||||||
);
|
</>
|
||||||
})()}
|
) : (
|
||||||
|
<>
|
||||||
|
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||||
|
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{tagMenuOpen && (
|
{tagMenuOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left 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>
|
>
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
@@ -3012,7 +3127,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3034,7 +3149,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3047,7 +3162,7 @@ export function EmailViewer({
|
|||||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Print — hidden on mobile, overflows to More menu */}
|
{/* Print - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3061,7 +3176,7 @@ export function EmailViewer({
|
|||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* View source — hidden on mobile, overflows to More menu */}
|
{/* View source - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3089,7 +3204,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More menu — click-based */}
|
{/* More menu - click-based */}
|
||||||
<div ref={moreMenuRef} className="relative">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3135,7 +3250,7 @@ export function EmailViewer({
|
|||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('archive')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
{/* Overflow: move to folder — submenu */}
|
{/* Overflow: move to folder - submenu */}
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('move')}
|
onMouseEnter={() => setMoreMenuSub('move')}
|
||||||
@@ -3187,7 +3302,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag — submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
@@ -3203,21 +3318,24 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{moreMenuSub === 'tag' && (
|
{moreMenuSub === 'tag' && (
|
||||||
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left 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>
|
>
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
@@ -3393,21 +3511,24 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left 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>
|
>
|
||||||
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
@@ -3524,7 +3645,7 @@ export function EmailViewer({
|
|||||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
{/* Back button (for below-subject mode on tablet) */}
|
{/* Back button (for below-subject mode on tablet) */}
|
||||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -3553,14 +3674,18 @@ export function EmailViewer({
|
|||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Color tag dot */}
|
{/* Color tag dots */}
|
||||||
{currentColor && (() => {
|
{currentColors.length > 0 && (
|
||||||
const kw = emailKeywords.find(k => k.id === currentColor);
|
<span className="flex items-center gap-0.5">
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
{currentColors.map((tagId) => {
|
||||||
return dotClass ? (
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||||
) : null;
|
return (
|
||||||
})()}
|
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isImportant && (
|
{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">
|
<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')}
|
{t('important')}
|
||||||
@@ -3914,7 +4039,7 @@ export function EmailViewer({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||||
Spam Score
|
Spam Score
|
||||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-xs",
|
"text-xs",
|
||||||
@@ -4403,7 +4528,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<EditIcon className="w-3.5 h-3.5" />
|
<EditIcon className="w-3.5 h-3.5" />
|
||||||
@@ -4430,7 +4555,7 @@ export function EmailViewer({
|
|||||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||||
hasCalendarInvitation) && (
|
hasCalendarInvitation) && (
|
||||||
<div className="border-b border-border bg-muted/30 isolate">
|
<div className="border-b border-border bg-muted/30 isolate">
|
||||||
<div className="max-w-4xl mx-auto px-6 py-1.5">
|
<div className="max-w-6xl mx-auto px-6 py-1.5">
|
||||||
<div className="flex flex-col gap-3 isolate">
|
<div className="flex flex-col gap-3 isolate">
|
||||||
{/* External Content Controls */}
|
{/* External Content Controls */}
|
||||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||||
@@ -4449,7 +4574,11 @@ export function EmailViewer({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const senderEmail = email.from?.[0]?.email;
|
const senderEmail = email.from?.[0]?.email;
|
||||||
if (senderEmail) {
|
if (senderEmail) {
|
||||||
addTrustedSender(senderEmail);
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
setAllowExternalContent(true);
|
setAllowExternalContent(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -4672,7 +4801,7 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{isDraft && onEditDraft ? (
|
{isDraft && onEditDraft ? (
|
||||||
<button
|
<button
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||||
aria-label={t('tooltips.edit_draft')}
|
aria-label={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
@@ -4797,7 +4926,7 @@ export function EmailViewer({
|
|||||||
if (client && supportsSync) {
|
if (client && supportsSync) {
|
||||||
createContact(client, contactData).then(() => toast.success('Contact added'));
|
createContact(client, contactData).then(() => toast.success('Contact added'));
|
||||||
} else {
|
} else {
|
||||||
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||||
toast.success('Contact added');
|
toast.success('Contact added');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Display label override (e.g. "me") */
|
/** Display label override (e.g. "me") */
|
||||||
displayLabel?: string;
|
displayLabel?: string;
|
||||||
/** Called when user clicks "View contact" — receives the contact and email */
|
/** Called when user clicks "View contact" - receives the contact and email */
|
||||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Close on scroll so the popover does not float while content moves.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = () => handleClose();
|
||||||
|
document.addEventListener("scroll", handler, true);
|
||||||
|
window.addEventListener("resize", handler);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("scroll", handler, true);
|
||||||
|
window.removeEventListener("resize", handler);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleViewContact = () => {
|
const handleViewContact = () => {
|
||||||
if (onViewContact) {
|
if (onViewContact) {
|
||||||
onViewContact(contact ?? null, email);
|
onViewContact(contact ?? null, email);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function RichTextEditor({
|
|||||||
);
|
);
|
||||||
if (imageFiles.length === 0) return false;
|
if (imageFiles.length === 0) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((url) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
text: t('unlock_key_desc'),
|
text: t('unlock_key_desc'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
|
} else if (status.decryptionError === 'no-key') {
|
||||||
|
items.push({
|
||||||
|
icon: <Lock className="w-4 h-4" />,
|
||||||
|
text: t('status_encrypted_no_key'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldX className="w-4 h-4" />,
|
icon: <ShieldX className="w-4 h-4" />,
|
||||||
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
// Signature status
|
// Signature status
|
||||||
if (status.isSigned) {
|
if (status.isSigned) {
|
||||||
if (status.signatureValid === true) {
|
if (status.signatureValid === true) {
|
||||||
if (status.signerEmailMatch === false) {
|
if (status.selfSigned) {
|
||||||
|
items.push({
|
||||||
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
|
text: t('status_signed_self_signed'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
} else if (status.signerEmailMatch === false) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <AlertTriangle className="w-4 h-4" />,
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
text: t('status_signed_mismatch'),
|
text: t('status_signed_mismatch'),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ export function ThreadConversationView({
|
|||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
|
||||||
// Track which emails are expanded (most recent by default)
|
// Track which emails are expanded (most recent by default)
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -164,7 +169,9 @@ export function ThreadConversationView({
|
|||||||
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
||||||
{emails.map((email, index) => {
|
{emails.map((email, index) => {
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
const senderIsTrusted = senderEmail
|
||||||
|
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||||
|
: false;
|
||||||
return (
|
return (
|
||||||
<EmailCard
|
<EmailCard
|
||||||
key={email.id}
|
key={email.id}
|
||||||
@@ -175,7 +182,11 @@ export function ThreadConversationView({
|
|||||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||||
onTrustSender={senderEmail ? () => {
|
onTrustSender={senderEmail ? () => {
|
||||||
addTrustedSender(senderEmail);
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
toggleAllowExternal(email.id);
|
toggleAllowExternal(email.id);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onReply={onReply ? () => onReply(email) : undefined}
|
onReply={onReply ? () => onReply(email) : undefined}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
|
|||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
@@ -55,15 +56,25 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||||
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||||
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
|
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||||
const resolvedColorTag = (() => {
|
const resolvedColorTag = (() => {
|
||||||
if (colorTag) return colorTag;
|
if (colorTag) return colorTag;
|
||||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||||
@@ -131,15 +142,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -160,7 +175,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -170,76 +185,137 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
{isUnifiedView && email.accountId && accountColor && (
|
||||||
isUnread
|
<span
|
||||||
? "font-bold text-foreground"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
: "font-medium text-muted-foreground"
|
style={{ backgroundColor: accountColor }}
|
||||||
)}>
|
title={email.accountLabel}
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
/>
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{isStarred && (
|
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
|
||||||
)}
|
|
||||||
{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" />
|
|
||||||
)}
|
)}
|
||||||
|
<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(
|
||||||
|
'shrink-0 truncate',
|
||||||
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
|
)}>
|
||||||
|
{email.subject || '(no subject)'}
|
||||||
|
</span>
|
||||||
|
{inlinePreview && (
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{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 && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<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')} />
|
||||||
|
))}
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{resolvedKeywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
{isUnifiedView && email.accountId && accountColor && (
|
||||||
)}>
|
<span
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
{resolvedKeywordDef.label}
|
style={{ backgroundColor: accountColor }}
|
||||||
</span>
|
title={email.accountLabel}
|
||||||
)}
|
/>
|
||||||
<span className={cn(
|
)}
|
||||||
"text-xs tabular-nums",
|
<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">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{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">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +323,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -280,10 +357,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||||
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
|
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||||
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||||
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||||
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
|
const displayNames = showRecipient
|
||||||
|
? Array.from(new Set(
|
||||||
|
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
|
||||||
|
)).slice(0, 4)
|
||||||
|
: participantNames;
|
||||||
|
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||||
|
|
||||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||||
email: latestEmail,
|
email: latestEmail,
|
||||||
@@ -304,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const threadColor = getThreadColorTag(thread.emails);
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||||
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
@@ -405,15 +496,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleThreadCheckboxClick}
|
onClick={handleThreadCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -428,7 +523,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && !isFocusedMailLayout && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -460,98 +555,169 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={avatarPerson?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={avatarPerson?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||||
hasUnread
|
<span
|
||||||
? "font-bold text-foreground"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
: "font-medium text-muted-foreground"
|
style={{ backgroundColor: threadAccountColor }}
|
||||||
)}>
|
title={latestEmail.accountLabel}
|
||||||
{participantNames.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>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{hasStarred && (
|
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
|
||||||
)}
|
|
||||||
{hasAnswered && !hasForwarded && (
|
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{hasForwarded && !hasAnswered && (
|
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
|
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={cn(
|
||||||
|
'shrink-0 truncate',
|
||||||
|
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
|
)}>
|
||||||
|
{latestEmail.subject || '(no subject)'}
|
||||||
|
</span>
|
||||||
|
{inlinePreview && (
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{hasAnswered && hasForwarded && (
|
{hasAnswered && hasForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && (
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<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')} />
|
||||||
)}
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||||
)}>
|
<span
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
{keywordDef.label}
|
style={{ backgroundColor: threadAccountColor }}
|
||||||
</span>
|
title={latestEmail.accountLabel}
|
||||||
)}
|
/>
|
||||||
<span className={cn(
|
)}
|
||||||
"text-xs tabular-nums",
|
<span className={cn(
|
||||||
|
"truncate text-sm",
|
||||||
|
hasUnread
|
||||||
|
? "font-bold text-foreground"
|
||||||
|
: "font-medium text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{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>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && !hasForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasForwarded && !hasAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && hasForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{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">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
hasUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
hasUnread
|
hasUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(latestEmail.receivedAt)}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
hasUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
hasUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{latestEmail.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{latestEmail.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
hasUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{latestEmail.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,6 +725,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
email={latestEmail}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
@@ -568,7 +735,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && (
|
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function FileBrowser({
|
|||||||
// Filter and sort resources
|
// Filter and sort resources
|
||||||
const displayResources = useMemo(() => {
|
const displayResources = useMemo(() => {
|
||||||
let filtered = resources;
|
let filtered = resources;
|
||||||
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
|
// In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
|
||||||
if (folderLayout === "sidebar") {
|
if (folderLayout === "sidebar") {
|
||||||
filtered = filtered.filter(r => !r.isDirectory);
|
filtered = filtered.filter(r => !r.isDirectory);
|
||||||
}
|
}
|
||||||
@@ -837,7 +837,7 @@ export function FileBrowser({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex flex-col h-full"
|
className="flex flex-col flex-1 min-h-0"
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -1466,7 +1466,7 @@ export function FileBrowser({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||||
{resource.isDirectory ? "—" : formatFileSize(resource.contentLength)}
|
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
||||||
{formatDate(resource.lastModified)}
|
{formatDate(resource.lastModified)}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||||
title={name}
|
title={name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't cache empty root results — empty root likely means client wasn't ready yet
|
// Don't cache empty root results - empty root likely means client wasn't ready yet
|
||||||
if (folders.length > 0 || parentId !== null) {
|
if (folders.length > 0 || parentId !== null) {
|
||||||
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import type {
|
|||||||
FilterActionType,
|
FilterActionType,
|
||||||
} from "@/lib/jmap/sieve-types";
|
} from "@/lib/jmap/sieve-types";
|
||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -58,6 +59,7 @@ export function FilterRuleModal({
|
|||||||
}: FilterRuleModalProps) {
|
}: FilterRuleModalProps) {
|
||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const isEdit = !!rule;
|
const isEdit = !!rule;
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || "");
|
const [name, setName] = useState(rule?.name || "");
|
||||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||||
@@ -109,7 +111,7 @@ export function FilterRuleModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
id: rule?.id || crypto.randomUUID(),
|
id: rule?.id || generateUUID(),
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
enabled: rule?.enabled ?? true,
|
enabled: rule?.enabled ?? true,
|
||||||
matchType,
|
matchType,
|
||||||
@@ -375,12 +377,17 @@ export function FilterRuleModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{action.type === "add_label" && (
|
{action.type === "add_label" && (
|
||||||
<Input
|
<select
|
||||||
value={action.value || ""}
|
value={action.value || ""}
|
||||||
onChange={(e) => updateAction(index, { value: e.target.value })}
|
onChange={(e) => updateAction(index, { value: e.target.value })}
|
||||||
placeholder={t("label_placeholder")}
|
className={`${selectClass} flex-1 min-w-[140px]`}
|
||||||
className="flex-1 min-w-[140px]"
|
aria-label={t("label_placeholder")}
|
||||||
/>
|
>
|
||||||
|
<option value="">{t("label_placeholder")}</option>
|
||||||
|
{emailKeywords.map((kw) => (
|
||||||
|
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ function useSyncIdentities() {
|
|||||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||||
return syncIdentities;
|
return syncIdentities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useRefreshIdentities() {
|
||||||
|
return useAuthStore((state) => state.refreshIdentities);
|
||||||
|
}
|
||||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||||
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||||
const syncIdentities = useSyncIdentities();
|
const syncIdentities = useSyncIdentities();
|
||||||
|
|
||||||
|
const refreshIdentitiesFromServer = useRefreshIdentities();
|
||||||
|
|
||||||
|
// Refresh identities from server whenever the modal is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
refreshIdentitiesFromServer();
|
||||||
|
}
|
||||||
|
}, [isOpen, refreshIdentitiesFromServer]);
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -16,8 +16,13 @@ import { useWebDAVStore } from "@/stores/webdav-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
|
import { getInitials } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -164,27 +169,67 @@ export function NavigationRail({
|
|||||||
const { mailboxes } = useEmailStore();
|
const { mailboxes } = useEmailStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
|
||||||
|
// Account list for rail
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
|
||||||
|
const logoutBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const logoutPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
|
||||||
|
const updateLogoutPosition = useCallback(() => {
|
||||||
|
if (!logoutBtnRef.current) return;
|
||||||
|
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||||
|
setLogoutPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoutMenuOpen) return;
|
||||||
|
updateLogoutPosition();
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
logoutBtnRef.current?.contains(e.target as Node) ||
|
||||||
|
logoutPopoverRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [logoutMenuOpen, updateLogoutPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const { client } = useAuthStore.getState();
|
const headers = getActiveAccountSlotHeaders();
|
||||||
if (!client) return;
|
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||||
const headers: Record<string, string> = {
|
apiFetch('/api/admin/stalwart-check', { headers })
|
||||||
'Authorization': client.getAuthHeader(),
|
|
||||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
|
||||||
'X-JMAP-Username': client.getUsername(),
|
|
||||||
};
|
|
||||||
fetch('/api/admin/stalwart-check', { headers })
|
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!cancelled && data.isStalwartAdmin) {
|
if (!cancelled && data.isStalwartAdmin) {
|
||||||
setIsStalwartAdmin(true);
|
setIsStalwartAdmin(true);
|
||||||
// Pre-create admin session so /admin works even after full page navigation
|
// Pre-create admin session so /admin works even after full page navigation
|
||||||
fetch('/api/admin/auth', {
|
apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...headers },
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
@@ -199,7 +244,7 @@ export function NavigationRail({
|
|||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||||
@@ -504,6 +549,22 @@ export function NavigationRail({
|
|||||||
<Keyboard className="w-[18px] h-[18px]" />
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!onShowShortcuts && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcutsModal(true)}
|
||||||
|
data-tour="nav-shortcuts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("keyboard_shortcuts")}
|
||||||
|
>
|
||||||
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{quota && quota.total > 0 && (
|
{quota && quota.total > 0 && (
|
||||||
<div data-tour="storage-quota">
|
<div data-tour="storage-quota">
|
||||||
@@ -511,21 +572,85 @@ export function NavigationRail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPushConnected != null && (
|
{onLogout && showRailAccountList && accounts.length > 0 && (
|
||||||
<span
|
<>
|
||||||
className="relative group"
|
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
|
||||||
>
|
{/* Account circles */}
|
||||||
<span
|
<div className="flex flex-col items-center gap-3">
|
||||||
className={cn(
|
{accounts.map((account) => {
|
||||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
const isActive = account.id === activeAccountId;
|
||||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||||
)}
|
return (
|
||||||
/>
|
<button
|
||||||
</span>
|
key={account.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) switchAccount(account.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||||
|
isActive
|
||||||
|
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||||
|
: "opacity-70 hover:opacity-100"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: account.avatarColor }}
|
||||||
|
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Check className="w-2 h-2 text-primary-foreground" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout button with popover */}
|
||||||
|
<button
|
||||||
|
ref={logoutBtnRef}
|
||||||
|
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("sign_out")}
|
||||||
|
aria-expanded={logoutMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{logoutMenuOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={logoutPopoverRef}
|
||||||
|
style={logoutPopoverStyle}
|
||||||
|
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogoutMenuOpen(false); logout(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out")}
|
||||||
|
</button>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_all")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onLogout && (
|
{onLogout && !showRailAccountList && (
|
||||||
<AccountSwitcher variant="rail" />
|
<AccountSwitcher variant="rail" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
||||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, generateUUID } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { IconPicker } from './icon-picker';
|
import { IconPicker } from './icon-picker';
|
||||||
@@ -155,7 +155,7 @@ function SidebarAppForm({
|
|||||||
{t('icon_label')} <span className="text-destructive">*</span>
|
{t('icon_label')} <span className="text-destructive">*</span>
|
||||||
{SelectedIcon && (
|
{SelectedIcon && (
|
||||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
const id = `app-${generateUUID()}`;
|
||||||
addSidebarApp({ id, ...data });
|
addSidebarApp({ id, ...data });
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}, [addSidebarApp]);
|
}, [addSidebarApp]);
|
||||||
|
|||||||
+600
-299
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user