Compare commits
200
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c7f74420 | ||
|
|
c2e4518cfa | ||
|
|
00f33afdf9 | ||
|
|
e566cfe687 | ||
|
|
6b7c849332 | ||
|
|
30c4afb977 | ||
|
|
1f60671886 | ||
|
|
794001fdbd | ||
|
|
9ad2facad3 | ||
|
|
89d8282846 | ||
|
|
c3960a99be | ||
|
|
e73ffa7449 | ||
|
|
37bd490072 | ||
|
|
24c53e5ce7 | ||
|
|
aa7f886795 | ||
|
|
00dec8c5a0 | ||
|
|
578e60c0bc | ||
|
|
8b21851353 | ||
|
|
15006086d2 | ||
|
|
bc3b923945 | ||
|
|
76ba9e5f85 | ||
|
|
44eb5fced2 | ||
|
|
172d8267ef | ||
|
|
f162f1e3d4 | ||
|
|
6fa0029d0b | ||
|
|
028e78a0c9 | ||
|
|
440a4e919a | ||
|
|
b8f39198e1 | ||
|
|
966bbe3957 | ||
|
|
522bf6a019 | ||
|
|
1689315c3a | ||
|
|
d4f7ae522e | ||
|
|
f05f70a9e5 | ||
|
|
850ee73048 | ||
|
|
5842f3f914 | ||
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f | ||
|
|
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 |
+3
-3
@@ -1,11 +1,11 @@
|
||||
# Bulwark Webmail — Development Configuration
|
||||
# Bulwark Webmail - Development Configuration
|
||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||
# No external mail server required — great for UI development and testing.
|
||||
# No external mail server required - great for UI development and testing.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.dev.example .env.local
|
||||
# npm run dev
|
||||
# Open http://localhost:3000 — log in with any username/password.
|
||||
# Open http://localhost:3000 - log in with any username/password.
|
||||
|
||||
# =============================================================================
|
||||
# Mock JMAP Server
|
||||
|
||||
+69
-23
@@ -1,4 +1,4 @@
|
||||
# Bulwark Webmail — Production Configuration
|
||||
# Bulwark Webmail - Production Configuration
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
# For development with the built-in mock server, see .env.dev.example instead.
|
||||
|
||||
@@ -6,12 +6,19 @@
|
||||
# JMAP Server (required)
|
||||
# =============================================================================
|
||||
|
||||
# App name displayed in the UI
|
||||
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||
APP_NAME=Bulwark Webmail
|
||||
|
||||
# URL of your JMAP-compatible mail server (required)
|
||||
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -20,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||
# STALWART_FEATURES=true
|
||||
|
||||
# If your reverse proxy doesn't forward Stalwart management API paths
|
||||
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
|
||||
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
|
||||
# STALWART_API_URL=https://admin.example.com
|
||||
|
||||
# =============================================================================
|
||||
# OAuth / OpenID Connect (optional)
|
||||
# =============================================================================
|
||||
@@ -41,6 +43,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# OAuth client secret (server-side only, never exposed to the browser)
|
||||
# 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
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
@@ -53,6 +57,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Required for both "Remember me" and settings sync features.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# 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
|
||||
@@ -65,7 +71,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||
# For Docker, the working directory is /app, so the default resolves to
|
||||
# /app/data/settings — mount a persistent volume there:
|
||||
# /app/data/settings - mount a persistent volume there:
|
||||
# volumes:
|
||||
# - bulwark-settings:/app/data/settings
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
@@ -95,41 +101,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Branding (all optional)
|
||||
# =============================================================================
|
||||
|
||||
# Custom favicon for the browser tab.
|
||||
# ---------------------------------------------------------------------------
|
||||
# App identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Short name for the app, used in contexts where space is limited
|
||||
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||
# APP_SHORT_NAME=Bulwark
|
||||
|
||||
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||
# Defaults to a generic Bulwark description if not set.
|
||||
# APP_DESCRIPTION=Your personal webmail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icons & favicon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Custom favicon shown in the browser tab.
|
||||
# Supported formats: SVG (recommended), PNG, ICO.
|
||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
||||
# Can be an absolute URL or a path relative to the public/ directory.
|
||||
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||
# Defaults to the Bulwark favicon if not set.
|
||||
# FAVICON_URL=/branding/my-favicon.svg
|
||||
|
||||
# Custom logos for the sidebar (shown in the main app after login).
|
||||
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||
# PWA_ICON_URL=/branding/my-icon.svg
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA appearance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||
# (address bar, status bar on Android). Default: #ffffff
|
||||
# PWA_THEME_COLOR=#3b82f6
|
||||
|
||||
# Background color shown on the PWA splash screen while the app is loading.
|
||||
# Should match your app's main background color. Default: #ffffff
|
||||
# PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Logos shown in the sidebar (main app, after login).
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 24×24px, max 128×128px
|
||||
# Recommended size: min 24×24px, max 128×128px.
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# If not set, no logo is shown in the sidebar.
|
||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||
|
||||
# Custom logo images for the login page.
|
||||
# Logos shown on the login page.
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 32×32px, max 512×512px
|
||||
# Recommended size: min 32×32px, max 512×512px.
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
||||
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||
#
|
||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
||||
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||
|
||||
# Company or organization name displayed above the version on the login page
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Company name shown above the version number on the login page.
|
||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
|
||||
# URL for the imprint/legal notice link on the login page
|
||||
# URL for the imprint / legal notice link on the login page.
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
|
||||
# URL for the privacy policy link on the login page
|
||||
# URL for the privacy policy link on the login page.
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# URL for the company website link on the login page
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||
|
||||
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:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -57,7 +69,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
images: ${{ needs.prepare.outputs.image_name }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
@@ -66,7 +78,7 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
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-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -86,7 +98,7 @@ jobs:
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [prepare, build]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -113,17 +125,17 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
images: ${{ needs.prepare.outputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value={{branch}}
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest
|
||||
type=sha
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
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
|
||||
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
|
||||
|
||||
## 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)
|
||||
|
||||
### Features
|
||||
@@ -287,7 +480,7 @@
|
||||
### Fixes
|
||||
|
||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||
|
||||
## 1.1.1 (2026-02-28)
|
||||
@@ -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**: Details toggle text is now properly translated (was hardcoded in English)
|
||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||
|
||||
## 1.1.0 (2026-02-28)
|
||||
|
||||
|
||||
+15
-4
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
||||
<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_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
@@ -12,6 +12,17 @@
|
||||
|
||||
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
|
||||
|
||||
### Development Setup
|
||||
@@ -195,7 +206,7 @@ webmail/
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
|
||||
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||
|
||||
**Calendar** — Month, week, day, and agenda views with event management
|
||||
**Calendar** - Month, week, day, and agenda views with event management
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||
|
||||
**Contacts** — Contact management with groups and vCard support
|
||||
**Contacts** - Contact management with groups and vCard support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||
|
||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||
|
||||
**Light mode** — Full theme support with intelligent color transformation
|
||||
**Light mode** - Full theme support with intelligent color transformation
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||
|
||||
**Settings** — Appearance, identities, filters, templates, and more
|
||||
**Settings** - Appearance, identities, filters, templates, and more
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||
|
||||
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -94,34 +94,46 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
### Mail
|
||||
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
||||
- **Draft auto-save** with discard confirmation
|
||||
- **Attachments** — upload, download, and inline preview
|
||||
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
||||
- **Archive modes** — archive directly or organize archived mail by year or month
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||
- **Unified mailbox** - view emails across all accounts in a single list
|
||||
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||
- **Print** emails directly from the viewer
|
||||
- **Answered/forwarded status icons** in email list and thread views
|
||||
- **Color tags/labels** and star/unstar
|
||||
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||
- **Star/unstar** with configurable mark-as-read delay
|
||||
- **Virtual scrolling** for large mailboxes
|
||||
- **Quick reply** from the viewer
|
||||
- **Sender avatars** — favicon-based with negative caching for performance
|
||||
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||
- **Sender avatars** - favicon-based with negative caching for performance
|
||||
- **Recipient popover** for quick contact interaction
|
||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** — unread and total counts displayed in sidebar
|
||||
- **Plain text composer mode** and auto-select reply identity
|
||||
- **Reply-to addresses** support in the composer
|
||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
- **message/rfc822 unwrapping** for embedded messages
|
||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** - unread and total counts displayed in sidebar
|
||||
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||
|
||||
### Calendar
|
||||
|
||||
- **Month, week, day, and agenda views** with mini-calendar sidebar
|
||||
- **Event hover preview** popover with details
|
||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview and bulk create
|
||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
||||
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
|
||||
- **Event hover preview** popover with configurable details
|
||||
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||
- **iCal / webcal subscriptions** with editing and batch import
|
||||
- **Birthday calendar** - auto-generated from contacts
|
||||
- **Virtual locations** - video conference URLs as first-class event fields
|
||||
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||
- **Shared calendars** with visual grouping in the sidebar
|
||||
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||
- **Week numbers** in mini-calendar sidebar
|
||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||
- **Real-time sync** via JMAP push
|
||||
@@ -129,77 +141,103 @@ Built with Next.js and the JMAP protocol.
|
||||
### Contacts
|
||||
|
||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||
- **Contact groups** with group expansion and member management
|
||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||
- **Trusted senders** stored in a dedicated JMAP address book
|
||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||
- **Bulk operations** — multi-select, delete, group add, export
|
||||
- **Bulk operations** - multi-select, delete, group add, export
|
||||
|
||||
### Filters & Automation
|
||||
|
||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||
- **Raw Sieve editor** with syntax validation
|
||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||
|
||||
### Files
|
||||
|
||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||
- **Upload and download** files with progress tracking and folder upload support
|
||||
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||
- **Grid and list views** with sorting by name, size, or date
|
||||
- **Clipboard operations** — cut, copy, paste, duplicate files
|
||||
- **Clipboard operations** - cut, copy, paste, duplicate files
|
||||
- **File preview** for images, text, audio, video, and more
|
||||
- **Favorites and recent files** for quick access
|
||||
- **Bulk operations** — multi-select, delete, move, download
|
||||
- **Bulk operations** - multi-select, delete, move, download
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **External content blocked** by default — trusted senders list for auto-load
|
||||
- **External content blocked** by default - trusted senders list for auto-load
|
||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||
- **SPF/DKIM/DMARC** status indicators
|
||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
|
||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||
- **TOTP two-factor authentication**
|
||||
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
||||
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||
- **Newsletter unsubscribe** (RFC 2369)
|
||||
|
||||
### Interface
|
||||
|
||||
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||
- **Dark and light themes** with intelligent email color transformation
|
||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
||||
- **Keyboard shortcuts** — full navigation without a mouse
|
||||
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||
- **Keyboard shortcuts** - full navigation without a mouse
|
||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||
- **Interactive guided tour** — onboarding walkthrough for new users
|
||||
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
||||
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||
- **Storage quota** display
|
||||
- **Shared folders** — multi-account access
|
||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
- **Version badge** in settings
|
||||
- **Focused mode** with proper viewport bounds
|
||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
|
||||
### Internationalization
|
||||
|
||||
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference.
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
### Identity Management
|
||||
|
||||
- **Multiple sender identities** with per-identity signatures
|
||||
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Identity badges** in viewer and email list
|
||||
|
||||
### Multi-Account
|
||||
|
||||
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||
- **Shared folders** across accounts
|
||||
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
### Admin & Extensibility
|
||||
|
||||
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||
|
||||
### Operations
|
||||
|
||||
- **Automatic update check** — server logs when a newer release is available
|
||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
||||
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||
- **Automatic update check** - server logs when a newer release is available
|
||||
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||
|
||||
---
|
||||
|
||||
@@ -217,7 +255,7 @@ Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local — set JMAP_SERVER_URL
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -228,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local — set JMAP_SERVER_URL
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
npm run build && npm start
|
||||
```
|
||||
|
||||
@@ -252,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
@@ -271,6 +309,7 @@ PORT=3000 # Default listen port
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||
```
|
||||
|
||||
@@ -279,13 +318,78 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Remember Me</summary>
|
||||
<summary>Remember Me & Settings Sync</summary>
|
||||
|
||||
```env
|
||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||
|
||||
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
|
||||
```
|
||||
|
||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom JMAP Endpoint</summary>
|
||||
|
||||
```env
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||
```
|
||||
|
||||
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Branding & PWA</summary>
|
||||
|
||||
```env
|
||||
APP_NAME=My Webmail
|
||||
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||
|
||||
FAVICON_URL=/branding/favicon.svg
|
||||
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
|
||||
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
|
||||
|
||||
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||
|
||||
LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extension Directory</summary>
|
||||
|
||||
```env
|
||||
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
```
|
||||
|
||||
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Stalwart Integration & Logging</summary>
|
||||
|
||||
```env
|
||||
STALWART_FEATURES=true # Password change, sieve filters, etc.
|
||||
|
||||
LOG_FORMAT=text # "text" or "json"
|
||||
LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -319,7 +423,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
||||
|
||||
## Why Stalwart?
|
||||
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
||||
const savedState = sessionStorage.getItem("oauth_state");
|
||||
|
||||
if (savedState) {
|
||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
||||
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||
if (!state || state !== savedState) {
|
||||
setError("invalid_state");
|
||||
return;
|
||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} else if (state) {
|
||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
|
||||
+210
-18
@@ -30,6 +30,10 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||
@@ -39,10 +43,13 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
type PendingScopeAction =
|
||||
@@ -66,12 +73,13 @@ export default function CalendarPage() {
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
refreshAllSubscriptions,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
|
||||
const currentUserEmails = useMemo(() =>
|
||||
@@ -82,6 +90,7 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
@@ -107,6 +116,16 @@ export default function CalendarPage() {
|
||||
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
|
||||
// Keep detailEvent in sync with store events (e.g. after update + refetch)
|
||||
useEffect(() => {
|
||||
if (detailEvent) {
|
||||
const updated = events.find(e => e.id === detailEvent.id);
|
||||
if (updated && updated !== detailEvent) {
|
||||
setDetailEvent(updated);
|
||||
}
|
||||
}
|
||||
}, [events, detailEvent]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth().finally(() => {
|
||||
@@ -146,6 +165,18 @@ export default function CalendarPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [client, refreshAllSubscriptions]);
|
||||
|
||||
// Auto-add birthday calendar to selected IDs only when the setting flips
|
||||
// off→on. Firing on every mount would undo a user's manual hide via the
|
||||
// sidebar each time they navigate back to the calendar (see #204).
|
||||
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
|
||||
useEffect(() => {
|
||||
const wasShown = prevShowBirthdayRef.current;
|
||||
prevShowBirthdayRef.current = showBirthdayCalendar;
|
||||
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||
}
|
||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const d = selectedDate;
|
||||
switch (normalizedViewMode) {
|
||||
@@ -335,20 +366,42 @@ export default function CalendarPage() {
|
||||
openEditModal(event);
|
||||
}, [closeDetail, openEditModal]);
|
||||
|
||||
const {
|
||||
contextMenu: eventContextMenu,
|
||||
openContextMenu: openEventContextMenu,
|
||||
closeContextMenu: closeEventContextMenu,
|
||||
menuRef: eventContextMenuRef,
|
||||
} = useContextMenu<CalendarEvent>();
|
||||
|
||||
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
closeDetail();
|
||||
openEventContextMenu(e, event);
|
||||
}, [closeDetail, openEventContextMenu]);
|
||||
|
||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||
if (isMobile) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
// Don't show hover popover if the sidebar is already open for this event
|
||||
if (showEventModal && editEvent?.id === event.id) return;
|
||||
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}, [isMobile, showEventModal, editEvent]);
|
||||
}, ms);
|
||||
} else {
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}
|
||||
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(null);
|
||||
setDetailAnchorRect(null);
|
||||
}, 200);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = useCallback(() => {
|
||||
@@ -385,12 +438,30 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh calendar data via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
await Promise.all([
|
||||
fetchCalendars(client),
|
||||
refetchCurrentRange(),
|
||||
refreshAllSubscriptions(client),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||
if (!event.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDate = parseISO(event.start);
|
||||
const eventDate = getEventStartDate({
|
||||
start: event.start,
|
||||
utcStart: event.utcStart ?? null,
|
||||
showWithoutTime: event.showWithoutTime ?? false,
|
||||
});
|
||||
if (Number.isNaN(eventDate.getTime())) {
|
||||
return;
|
||||
}
|
||||
@@ -696,6 +767,73 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [detailEvent, client, updateEvent, t]);
|
||||
|
||||
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
const start = parseISO(event.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
});
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
if (created) {
|
||||
toast.success(t("notifications.event_duplicated"));
|
||||
openEditModal(created);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, createEvent, openEditModal, t]);
|
||||
|
||||
const handleExportICS = useCallback((event: CalendarEvent) => {
|
||||
try {
|
||||
downloadEventICS(event);
|
||||
toast.success(t("notifications.event_exported"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(event.title || "");
|
||||
toast.success(t("notifications.title_copied"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
|
||||
const uri = event.virtualLocations
|
||||
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
|
||||
: undefined;
|
||||
if (!uri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(uri);
|
||||
toast.success(t("notifications.link_copied"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
|
||||
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
|
||||
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||
}, [handleDeleteEvent]);
|
||||
|
||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||
if (!detailEvent || !client) return;
|
||||
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||
@@ -727,14 +865,31 @@ export default function CalendarPage() {
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||
|
||||
const visibleEvents = useMemo(() =>
|
||||
events.filter((e) => {
|
||||
const birthdayEvents = useMemo(() => {
|
||||
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;
|
||||
const calIds = Object.keys(e.calendarIds);
|
||||
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(() => {
|
||||
const hiddenEvents = events.filter((event) => {
|
||||
@@ -749,7 +904,7 @@ export default function CalendarPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Calendar visibility summary', {
|
||||
debug.log('calendar', 'Calendar visibility summary', {
|
||||
totalEvents: events.length,
|
||||
visibleEvents: visibleEvents.length,
|
||||
hiddenEvents: hiddenEvents.length,
|
||||
@@ -783,11 +938,12 @@ export default function CalendarPage() {
|
||||
<CalendarMonthView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
@@ -799,11 +955,12 @@ export default function CalendarPage() {
|
||||
<CalendarWeekView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
timeFormat={timeFormat}
|
||||
@@ -818,10 +975,11 @@ export default function CalendarPage() {
|
||||
<CalendarDayView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onCreateAtTime={openCreateModal}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
@@ -835,10 +993,11 @@ export default function CalendarPage() {
|
||||
<CalendarAgendaView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
@@ -873,7 +1032,7 @@ export default function CalendarPage() {
|
||||
})();
|
||||
|
||||
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}
|
||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
@@ -926,13 +1085,18 @@ export default function CalendarPage() {
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
onColorChange={client ? (calendarId, color) => {
|
||||
if (calendarId === BIRTHDAY_CALENDAR_ID) {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
@@ -1048,6 +1212,22 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventContextMenu.data && (
|
||||
<EventContextMenu
|
||||
event={eventContextMenu.data}
|
||||
position={eventContextMenu.position}
|
||||
isOpen={eventContextMenu.isOpen}
|
||||
onClose={closeEventContextMenu}
|
||||
menuRef={eventContextMenuRef}
|
||||
onEdit={() => openEditModal(eventContextMenu.data!)}
|
||||
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
|
||||
onExportICS={() => handleExportICS(eventContextMenu.data!)}
|
||||
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
|
||||
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
|
||||
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
@@ -1099,6 +1279,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} />
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
|
||||
@@ -13,18 +13,20 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-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 { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
|
||||
type View =
|
||||
@@ -72,12 +74,16 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
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 hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -118,6 +124,16 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh contacts via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsSync,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
await fetchContacts(client);
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||
@@ -243,9 +259,7 @@ export default function ContactsPage() {
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
|
||||
const deleteContactById = useCallback(async (contactId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm"),
|
||||
@@ -256,25 +270,49 @@ export default function ContactsPage() {
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
await deleteContact(client, selectedContact.id);
|
||||
await deleteContact(client, contactId);
|
||||
} else {
|
||||
deleteLocalContact(selectedContact.id);
|
||||
deleteLocalContact(contactId);
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
if (selectedContactId === contactId) setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
await deleteContactById(selectedContact.id);
|
||||
};
|
||||
|
||||
const handleEditContact = useCallback((id: string) => {
|
||||
setSelectedContact(id);
|
||||
setView("edit");
|
||||
}, [setSelectedContact]);
|
||||
|
||||
const handleDeleteContact = useCallback((contact: ContactCard) => {
|
||||
void deleteContactById(contact.id);
|
||||
}, [deleteContactById]);
|
||||
|
||||
const handleAddContactToGroup = useCallback((id: string) => {
|
||||
clearSelection();
|
||||
toggleContactSelection(id);
|
||||
if (groups.length === 0) {
|
||||
setView("group-create");
|
||||
return;
|
||||
}
|
||||
setView("bulk-add-to-group");
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: {},
|
||||
...data,
|
||||
};
|
||||
@@ -631,6 +669,8 @@ export default function ContactsPage() {
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -672,6 +712,9 @@ export default function ContactsPage() {
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -725,6 +768,46 @@ export default function ContactsPage() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<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 && (
|
||||
<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">
|
||||
|
||||
@@ -17,15 +17,19 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -124,6 +128,15 @@ export default function FilesPage() {
|
||||
}
|
||||
}, [isAuthenticated, client, initClient]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh files via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||||
onRefresh: async () => {
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
// Check support and load root after client is initialized
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
useEffect(() => {
|
||||
@@ -392,12 +405,25 @@ export default function FilesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{supportsFiles === false ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!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">
|
||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||
</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
|
||||
currentPath={currentPath}
|
||||
resources={resources}
|
||||
@@ -442,6 +468,7 @@ export default function FilesPage() {
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+91
-17
@@ -10,13 +10,15 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } 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 { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
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 = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
@@ -24,6 +26,41 @@ const THEME_OPTIONS = [
|
||||
{ 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() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
@@ -32,13 +69,14 @@ export default function LoginPage() {
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: 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 [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
@@ -73,6 +111,12 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [appName, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverUrl && !jmapEndpoint) {
|
||||
setJmapEndpoint(serverUrl);
|
||||
}
|
||||
}, [serverUrl, jmapEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||
@@ -91,6 +135,14 @@ export default function LoginPage() {
|
||||
prevError.current = 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(() => {
|
||||
if (!serverUrl) return;
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
@@ -180,8 +232,9 @@ export default function LoginPage() {
|
||||
const startServerSideSso = useCallback(async () => {
|
||||
setOauthLoading(true);
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||
const res = await fetch('/api/auth/sso/start', {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
const res = await apiFetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -202,7 +255,7 @@ export default function LoginPage() {
|
||||
try {
|
||||
window.top!.location.href = authorize_url;
|
||||
} catch {
|
||||
// Cross-origin restriction — fall back to current frame
|
||||
// Cross-origin restriction - fall back to current frame
|
||||
window.location.href = authorize_url;
|
||||
}
|
||||
} else {
|
||||
@@ -264,7 +317,7 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverUrl && !demoMode) {
|
||||
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||
return (
|
||||
<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">
|
||||
@@ -362,7 +415,7 @@ export default function LoginPage() {
|
||||
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
@@ -382,8 +435,9 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
formData.password,
|
||||
totpCode || undefined,
|
||||
@@ -563,9 +617,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -707,7 +759,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Dev mode — logging in as dev@localhost
|
||||
Dev mode - logging in as dev@localhost
|
||||
</p>
|
||||
</div>
|
||||
) : oauthOnly ? (
|
||||
@@ -749,6 +801,27 @@ export default function LoginPage() {
|
||||
/* Login Form */
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<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 */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||
@@ -861,7 +934,10 @@ export default function LoginPage() {
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
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")}
|
||||
autoComplete="one-time-code"
|
||||
aria-label={t("totp_label")}
|
||||
@@ -1035,9 +1111,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+349
-69
@@ -9,16 +9,21 @@ import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
||||
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
@@ -37,6 +43,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -53,10 +60,12 @@ export default function Home() {
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -74,6 +83,16 @@ export default function Home() {
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
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(() => {
|
||||
if (!isRateLimited || !rateLimitUntil) {
|
||||
@@ -139,8 +158,150 @@ export default function Home() {
|
||||
hasMoreEmails,
|
||||
fetchTagCounts,
|
||||
fetchEmailContent,
|
||||
isUnifiedView,
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
refreshUnifiedCounts,
|
||||
exitUnifiedView,
|
||||
} = useEmailStore();
|
||||
|
||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
const result: UnifiedAccountClient[] = [];
|
||||
for (const account of connected) {
|
||||
const accountClient = clients.get(account.id);
|
||||
if (!accountClient) continue;
|
||||
result.push({
|
||||
accountId: account.id,
|
||||
accountLabel: account.label || account.email,
|
||||
client: accountClient,
|
||||
mailboxes: [],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const populateUnifiedAccountMailboxes = useCallback(
|
||||
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||
const populated = await Promise.all(
|
||||
list.map(async (entry) => {
|
||||
try {
|
||||
const mailboxes = await entry.client.getMailboxes();
|
||||
return { ...entry, mailboxes };
|
||||
} catch (err) {
|
||||
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||
return entry;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return populated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Browser back / forward integration. The restore handler reads the
|
||||
// latest values from a ref so we don't have to recreate the callback on
|
||||
// every render (and so the popstate listener is never stale).
|
||||
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
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
@@ -248,6 +409,20 @@ export default function Home() {
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh mail data via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const state = useEmailStore.getState();
|
||||
await Promise.all([
|
||||
state.fetchMailboxes(client),
|
||||
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = appName;
|
||||
@@ -350,13 +525,13 @@ export default function Home() {
|
||||
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('[Push] Push notifications successfully enabled');
|
||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('[Push] Push notifications not available on this server');
|
||||
debug.log('push', '[Push] Push notifications not available on this server');
|
||||
}
|
||||
} catch (error) {
|
||||
// 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) {
|
||||
console.error('Error loading email data:', error);
|
||||
@@ -373,13 +548,30 @@ export default function Home() {
|
||||
};
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
||||
|
||||
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||
// than one account is connected. Runs whenever the set of connected accounts
|
||||
// or the primary account's mailboxes change (a proxy for "something worth
|
||||
// recounting happened").
|
||||
useEffect(() => {
|
||||
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||
const built = buildUnifiedAccounts();
|
||||
if (built.length < 2) return;
|
||||
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||
refreshUnifiedCounts(populated);
|
||||
});
|
||||
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||
|
||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||
useEffect(() => {
|
||||
if (!selectedEmail || !client) return;
|
||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||
if (!selectedEmail.bodyValues) {
|
||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
setLoadingEmail(true);
|
||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
||||
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||
setLoadingEmail(false);
|
||||
});
|
||||
}
|
||||
@@ -390,7 +582,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
// Clear any existing timeout when email changes
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Clearing previous timeout');
|
||||
debug.log('email', '[Mark as Read] Clearing previous timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
@@ -402,20 +594,20 @@ export default function Home() {
|
||||
|
||||
// Get current setting value
|
||||
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) {
|
||||
// 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) {
|
||||
// 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);
|
||||
} else {
|
||||
// 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(() => {
|
||||
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);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}, markAsReadDelay);
|
||||
@@ -424,7 +616,7 @@ export default function Home() {
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return () => {
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
||||
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
@@ -439,7 +631,7 @@ export default function Home() {
|
||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||
playNotificationSound(notificationSoundChoice);
|
||||
}
|
||||
debug.log('New email received:', newEmailNotification.subject);
|
||||
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
}, [newEmailNotification, clearNewEmailNotification]);
|
||||
@@ -467,7 +659,7 @@ export default function Home() {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -519,7 +711,10 @@ export default function Home() {
|
||||
|
||||
const handleEditDraft = async (email?: Email) => {
|
||||
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;
|
||||
|
||||
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||
@@ -538,6 +733,16 @@ export default function Home() {
|
||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||
: undefined;
|
||||
|
||||
// Try to find the identity that matches the draft's from address to preserve it
|
||||
const draftFromEmail = draft.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find(id => id.email === draftFromEmail)
|
||||
: null;
|
||||
|
||||
// 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({
|
||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
@@ -546,7 +751,7 @@ export default function Home() {
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (draft.cc?.length || 0) > 0,
|
||||
showBcc: (draft.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: null,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: draft.id,
|
||||
@@ -568,8 +773,8 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToDelete) return;
|
||||
|
||||
// Check if we're currently in the trash or junk folder
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
@@ -588,7 +793,7 @@ export default function Home() {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteEmail(client, selectedEmail.id, true);
|
||||
await deleteEmail(client, emailToDelete.id, true);
|
||||
} catch (error) {
|
||||
console.error("Failed to permanently delete email:", error);
|
||||
}
|
||||
@@ -597,7 +802,7 @@ export default function Home() {
|
||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||
if (trashMailbox) {
|
||||
try {
|
||||
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
|
||||
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to move email to trash:", error);
|
||||
}
|
||||
@@ -636,7 +841,7 @@ export default function Home() {
|
||||
if (archiveMode === 'year') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||
} else {
|
||||
// archiveMode === 'month' — find or create month subfolder under year
|
||||
// archiveMode === 'month' - find or create month subfolder under year
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = mailboxes.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
@@ -670,10 +875,10 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsSpam = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToMark) return;
|
||||
|
||||
const emailId = selectedEmail.id;
|
||||
const emailId = emailToMark.id;
|
||||
|
||||
try {
|
||||
await markAsSpam(client, emailId);
|
||||
@@ -701,11 +906,11 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndoSpam = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToRestore) return;
|
||||
|
||||
try {
|
||||
await undoSpam(client, selectedEmail.id);
|
||||
await undoSpam(client, emailToRestore.id);
|
||||
|
||||
const toastInstance = (await import('sonner')).toast;
|
||||
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
||||
@@ -726,16 +931,22 @@ export default function Home() {
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
// Remove old label and legacy color tags - set to false for JMAP to remove them
|
||||
if (color === null) {
|
||||
// Remove all label/color tags
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Add new label tag if specified (using new $label: prefix)
|
||||
if (color) {
|
||||
keywords[`$label:${color}`] = true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Update email keywords via JMAP
|
||||
@@ -755,6 +966,32 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
if (isUnifiedMailboxId(mailboxId)) {
|
||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||
if (!role) return;
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
|
||||
const built = buildUnifiedAccounts();
|
||||
const populated = await populateUnifiedAccountMailboxes(built);
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
refreshUnifiedCounts(populated);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedView) {
|
||||
exitUnifiedView();
|
||||
}
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
|
||||
@@ -838,6 +1075,7 @@ export default function Home() {
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
if (isUnifiedView) return;
|
||||
setSearchQuery(query);
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
@@ -856,6 +1094,7 @@ export default function Home() {
|
||||
|
||||
const handleAdvancedSearch = async () => {
|
||||
if (!client) return;
|
||||
if (isUnifiedView) return;
|
||||
await advancedSearch(client);
|
||||
};
|
||||
|
||||
@@ -865,9 +1104,9 @@ export default function Home() {
|
||||
clearTimeout(advancedSearchDebounceRef.current);
|
||||
}
|
||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||
if (client) advancedSearch(client);
|
||||
if (client && !isUnifiedView) advancedSearch(client);
|
||||
}, 300);
|
||||
}, [client, advancedSearch]);
|
||||
}, [client, advancedSearch, isUnifiedView]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -967,6 +1206,10 @@ export default function Home() {
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
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
|
||||
const handleEmailSelect = async (email: { id: string }) => {
|
||||
@@ -992,13 +1235,29 @@ export default function Home() {
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// Find selected mailbox to determine accountId (for shared folders)
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
// In unified view each email carries its own accountId. Use that
|
||||
// account's client so we fetch from the server that actually owns it.
|
||||
const listEmail = emails.find(e => e.id === email.id);
|
||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||
const perAccountClient = emailAccountId
|
||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
|
||||
const fullEmail = await client.getEmail(email.id, accountId);
|
||||
// For shared folders on the primary client, we still need to pass the
|
||||
// shared account's id. In unified view we use the per-account client
|
||||
// directly, so no explicit accountId is needed.
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = perAccountClient
|
||||
? undefined
|
||||
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
if (emailAccountId) {
|
||||
fullEmail.accountId = emailAccountId;
|
||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||
}
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
}
|
||||
@@ -1009,14 +1268,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 = () => {
|
||||
// If in conversation view, clear it
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
selectEmail(null);
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
setActiveView("list");
|
||||
};
|
||||
|
||||
@@ -1197,12 +1466,12 @@ export default function Home() {
|
||||
"max-md:flex-1 max-md:border-r-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// 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",
|
||||
// Tablet: collapse when email selected
|
||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
shouldCollapseListPane && "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 */}
|
||||
<MobileHeader
|
||||
@@ -1252,6 +1521,8 @@ export default function Home() {
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -1267,13 +1538,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("advanced_search.toggle_filters")}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -1457,8 +1730,7 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onDelete={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleDelete();
|
||||
await handleDelete(email);
|
||||
}}
|
||||
onArchive={async (email) => {
|
||||
await handleArchive(email);
|
||||
@@ -1472,12 +1744,10 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onMarkAsSpam={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleMarkAsSpam();
|
||||
await handleMarkAsSpam(email);
|
||||
}}
|
||||
onUndoSpam={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleUndoSpam();
|
||||
await handleUndoSpam(email);
|
||||
}}
|
||||
onEditDraft={(email) => {
|
||||
handleEditDraft(email);
|
||||
@@ -1487,24 +1757,27 @@ export default function Home() {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Floating Compose Button (mobile) */}
|
||||
{isMobile && (
|
||||
{/* Floating Compose Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
setActiveView('viewer');
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
||||
aria-label={t('sidebar.compose')}
|
||||
>
|
||||
<PenSquare className="h-6 w-6" />
|
||||
</Button>
|
||||
className={cn(
|
||||
"absolute z-40 rounded-full shadow-lg",
|
||||
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||
)}
|
||||
aria-label={t('sidebar.compose')}
|
||||
title={t('sidebar.compose_hint')}
|
||||
data-tour="compose-button"
|
||||
>
|
||||
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
@@ -1521,7 +1794,8 @@ export default function Home() {
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative
|
||||
"md:relative"
|
||||
"md:relative",
|
||||
shouldHideViewerPane && "md:hidden"
|
||||
)}
|
||||
>
|
||||
{/* Inline Composer - shown in viewer pane */}
|
||||
@@ -1534,15 +1808,19 @@ export default function Home() {
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
@@ -1636,7 +1914,7 @@ export default function Home() {
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsSpam={handleMarkAsSpam}
|
||||
@@ -1648,14 +1926,15 @@ export default function Home() {
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onBack={() => {
|
||||
setTabletListVisible(true);
|
||||
selectEmail(null);
|
||||
}}
|
||||
onBack={handleMobileBack}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
@@ -1709,6 +1988,7 @@ export default function Home() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-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 { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
@@ -211,7 +212,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{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 === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
|
||||
const res = await fetch('/api/admin/branding', {
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/branding', {
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/change-password', {
|
||||
const res = await apiFetch('/api/admin/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
|
||||
+6
-10
@@ -24,8 +24,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
@@ -78,19 +80,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
}, [pathname]);
|
||||
|
||||
function getJmapHeaders(): Record<string, string> {
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return {};
|
||||
return {
|
||||
'Authorization': client.getAuthHeader(),
|
||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||
'X-JMAP-Username': client.getUsername(),
|
||||
};
|
||||
return getActiveAccountSlotHeaders();
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getJmapHeaders();
|
||||
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
@@ -109,7 +105,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await fetch('/api/admin/auth', {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
@@ -127,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/auth', {
|
||||
const res = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (actionFilter) params.set('action', actionFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/audit?${params}`);
|
||||
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
||||
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||
if (detail.changes && Array.isArray(detail.changes)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface Extension {
|
||||
slug: string;
|
||||
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
|
||||
params.set('perPage', String(perPage));
|
||||
params.set('sort', 'newest');
|
||||
|
||||
const res = await fetch(`/api/admin/marketplace?${params}`);
|
||||
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || 'Failed to connect to extension directory');
|
||||
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/marketplace', {
|
||||
const res = await apiFetch('/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
|
||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
|
||||
+13
-12
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface AdminStatus {
|
||||
enabled: boolean;
|
||||
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||
fetch('/api/admin/auth'),
|
||||
fetch('/api/admin/audit?limit=10'),
|
||||
fetch('/api/config'),
|
||||
fetch('/api/admin/config'),
|
||||
fetch('/api/admin/plugins').catch(() => null),
|
||||
fetch('/api/admin/themes').catch(() => null),
|
||||
fetch('/api/admin/policy').catch(() => null),
|
||||
apiFetch('/api/admin/auth'),
|
||||
apiFetch('/api/admin/audit?limit=10'),
|
||||
apiFetch('/api/config'),
|
||||
apiFetch('/api/admin/config'),
|
||||
apiFetch('/api/admin/plugins').catch(() => null),
|
||||
apiFetch('/api/admin/themes').catch(() => null),
|
||||
apiFetch('/api/admin/policy').catch(() => null),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
const jmapRes = await fetch('/api/config');
|
||||
const jmapRes = await apiFetch('/api/config');
|
||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||
} catch {
|
||||
setJmapHealth('error');
|
||||
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
|
||||
setWarnings(w);
|
||||
}
|
||||
|
||||
const jmapUrl = config?.jmapServerUrl || '—';
|
||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
||||
const jmapUrl = config?.jmapServerUrl || '-';
|
||||
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
|
||||
{/* Server Info */}
|
||||
<SettingsSection title="Server" description="Application and connection details">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Connection">
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
@@ -33,7 +35,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
@@ -59,11 +61,20 @@ export default function AdminPluginsPage() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleRequirePluginApproval() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
@@ -85,7 +96,7 @@ export default function AdminPluginsPage() {
|
||||
async function fetchPlugins() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/plugins');
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
if (res.ok) setPlugins(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -103,7 +114,7 @@ export default function AdminPluginsPage() {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -126,7 +137,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
@@ -146,7 +157,7 @@ export default function AdminPluginsPage() {
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -180,7 +191,7 @@ export default function AdminPluginsPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of disabled) {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||
@@ -206,7 +217,7 @@ export default function AdminPluginsPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of enabled) {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||
@@ -226,7 +237,7 @@ export default function AdminPluginsPage() {
|
||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
@@ -247,6 +258,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -319,6 +331,17 @@ export default function AdminPluginsPage() {
|
||||
</button>
|
||||
</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 */}
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
@@ -396,6 +419,13 @@ export default function AdminPluginsPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
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 type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// Feature gates managed on their own admin pages (excluded from this list)
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
@@ -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' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
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 = [
|
||||
@@ -28,10 +30,12 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ 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: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
{ 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: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||
@@ -51,7 +55,7 @@ export default function AdminPolicyPage() {
|
||||
async function fetchPolicy() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
@@ -103,7 +107,7 @@ export default function AdminPolicyPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) {
|
||||
setConfig(await res.json());
|
||||
}
|
||||
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
@@ -112,8 +113,15 @@ export default function AdminSettingsPage() {
|
||||
<SettingsSection title="General">
|
||||
<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" />
|
||||
<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} />
|
||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy({
|
||||
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
|
||||
async function fetchThemes() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes');
|
||||
const res = await apiFetch('/api/admin/themes');
|
||||
if (res.ok) setThemes(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
|
||||
|
||||
async function toggleTheme(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const t of disabled) {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const t of enabled) {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
|
||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/auth
|
||||
* Proxy to Stalwart GET /api/account/auth
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart auth info failed', { status: response.status });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch auth info', details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/auth
|
||||
* Proxy to Stalwart POST /api/account/auth
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart auth update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart GET /api/account/crypto
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch crypto info', details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart POST /api/account/crypto
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart crypto update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/jmap
|
||||
*
|
||||
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
|
||||
* context so the browser does not need access to the user's credentials.
|
||||
*
|
||||
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
|
||||
*
|
||||
* In Stalwart 0.16 all management operations (password change, app
|
||||
* passwords, API keys, account settings, etc.) are exposed as JMAP
|
||||
* methods under the `x:` namespace on the same endpoint.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/password
|
||||
* Change user password via Stalwart PATCH /api/principal/{name}
|
||||
*
|
||||
* Body: { currentPassword: string, newPassword: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = await request.json();
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify current password by attempting to authenticate
|
||||
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
|
||||
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': verifyAuth },
|
||||
});
|
||||
|
||||
if (!verifyResponse.ok) {
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Change password via Stalwart principal API
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{ action: 'set', field: 'secrets', value: newPassword },
|
||||
]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart password change failed', { status: response.status });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password', details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// If session cookie exists, update it with the new password
|
||||
if (creds.hasSessionCookie) {
|
||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/principal
|
||||
* Proxy to Stalwart GET /api/principal/{username}
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch principal', details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/account/stalwart/principal
|
||||
* Proxy to Stalwart PATCH /api/principal/{username}
|
||||
* Body: PrincipalUpdateAction[] (array of {action, field, value})
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Prevent secrets field from being changed through this endpoint (use /password instead)
|
||||
if (Array.isArray(body)) {
|
||||
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
|
||||
if (hasSecrets) {
|
||||
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart principal update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/probe
|
||||
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
|
||||
|
||||
return NextResponse.json({ isStalwart });
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
||||
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
|
||||
* Permissions that indicate Stalwart admin privileges.
|
||||
* If the authenticated user has at least one of these, they can manage
|
||||
* system-level resources and are considered an admin.
|
||||
*/
|
||||
const ADMIN_PERMISSIONS = [
|
||||
'sysAccountQuery',
|
||||
'sysTenantQuery',
|
||||
'sysSystemSettingsGet',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the current user is a Stalwart admin by inspecting the
|
||||
* permissions list returned by Stalwart's /api/account endpoint.
|
||||
*/
|
||||
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) return false;
|
||||
|
||||
// Probe admin-only endpoint: listing principals requires admin privileges
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
|
||||
const response = await fetch(`${creds.serverUrl}/api/account`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
const isAdmin = response.ok;
|
||||
if (!response.ok) {
|
||||
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json() as { permissions?: string[] };
|
||||
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
|
||||
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
|
||||
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
|
||||
return isAdmin;
|
||||
} catch (error) {
|
||||
@@ -30,7 +48,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/auth — Login
|
||||
* POST /api/admin/auth - Login
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -92,7 +110,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/auth — Check session status
|
||||
* GET /api/admin/auth - Check session status
|
||||
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -136,7 +154,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/auth — Logout
|
||||
* DELETE /api/admin/auth - Logout
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
||||
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||
*
|
||||
* This endpoint is public (no admin auth) so browsers can load images.
|
||||
* Only files in the branding directory are served; directory traversal is prevented.
|
||||
|
||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding — Upload a branding image file
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* POST /api/admin/change-password — Change admin password
|
||||
* POST /api/admin/change-password - Change admin password
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
||||
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
||||
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
* Proxies to the extension directory API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/marketplace — Install an extension from the directory
|
||||
* POST /api/admin/marketplace - Install an extension from the directory
|
||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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
|
||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as 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';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
*
|
||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||
* Only serves plugins that exist in the registry and are enabled.
|
||||
|
||||
@@ -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() {
|
||||
try {
|
||||
@@ -41,7 +41,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
||||
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
const code = await entryFile.async('string');
|
||||
|
||||
// Security warnings (logged but not blocking for admin)
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
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 plugin: ServerPlugin = {
|
||||
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
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) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
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 }
|
||||
*/
|
||||
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 }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
|
||||
/**
|
||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
||||
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -22,7 +22,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
||||
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/admin/stalwart-check
|
||||
* Check if the currently logged-in user is a Stalwart admin.
|
||||
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
||||
// Use limit=1 to minimize payload.
|
||||
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
const isStalwartAdmin = response.ok;
|
||||
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
||||
|
||||
return NextResponse.json({ isStalwartAdmin }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart admin check error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
||||
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes — List all admin-managed themes
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -32,7 +32,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
||||
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
||||
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||
* Body: { id: string, enabled: boolean }
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/themes — Remove a theme
|
||||
* DELETE /api/admin/themes - Remove a theme
|
||||
* Body: { id: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
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 = {
|
||||
...getCookieOptions(),
|
||||
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
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();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
setStalwartAuthContextInStore(cookieStore, 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('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
|
||||
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')}`,
|
||||
});
|
||||
|
||||
// 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, {
|
||||
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)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
} else {
|
||||
const slot = getSlot(request);
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -33,8 +34,8 @@ export async function GET() {
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!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 || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
@@ -47,6 +48,7 @@ export async function GET() {
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
|
||||
File diff suppressed because one or more lines are too long
+79
-14
@@ -1,9 +1,39 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
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;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
// Block private/internal hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.arpa') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('169.254.') ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
||||
hostname.endsWith('.localdomain')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -39,9 +64,26 @@ function isValidExternalUrl(urlString: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isValidExternalUrl(currentUrl))) {
|
||||
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: 'follow',
|
||||
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);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote server returned ${response.status}` },
|
||||
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
||||
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||
*
|
||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||
* No admin auth required — this is how regular users receive plugins/themes.
|
||||
* No admin auth required - this is how regular users receive plugins/themes.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
const size = parseInt(sizeParam, 10);
|
||||
|
||||
if (!VALID_SIZES.has(size)) {
|
||||
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||
}
|
||||
|
||||
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||
if (!iconUrl) {
|
||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
};
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA icon:', err);
|
||||
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||
}
|
||||
}
|
||||
+24
-12
@@ -3,8 +3,10 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
function classifyError(error: unknown): { message: string; status: number } {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
@@ -47,33 +49,43 @@ function classifyError(error: unknown): { message: string; status: number } {
|
||||
}
|
||||
|
||||
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.
|
||||
* 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> {
|
||||
const cookieStore = await cookies();
|
||||
let hasAnyCookie = false;
|
||||
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||
|
||||
for (let slot = 0; slot <= 4; slot++) {
|
||||
// Check basic-auth session cookie
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (!token) continue;
|
||||
hasAnyCookie = true;
|
||||
|
||||
if (token) {
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||
return true; // Found a matching slot
|
||||
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
||||
if (!hasAnyCookie) return true;
|
||||
// Check stalwart auth context cookie (set for all auth modes)
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Cookies exist but none matched → identity mismatch
|
||||
// No matching session found (or no cookies at all) → reject
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -123,7 +135,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Enforce admin policy — strip locked settings so users can't override them
|
||||
// Enforce admin policy - strip locked settings so users can't override them
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const filteredSettings = { ...settings };
|
||||
|
||||
+43
-13
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
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
|
||||
* 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 cleanPath = davPath.replace(/^\/+/, '');
|
||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||
const targetUrl = cleanPath
|
||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
||||
const baseUrl = creds.serverUrl.replace(/\/$/, '');
|
||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||
|
||||
// Build headers for the upstream request
|
||||
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
|
||||
const destination = request.headers.get('X-WebDAV-Destination');
|
||||
if (destination) {
|
||||
const cleanDest = destination.replace(/^\/+/, '');
|
||||
upstreamHeaders['Destination'] = cleanDest
|
||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
||||
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||
}
|
||||
|
||||
const overwrite = request.headers.get('Overwrite');
|
||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||
|
||||
// Forward request body for methods that need it
|
||||
let body: ArrayBuffer | null = null;
|
||||
if (method === 'PROPFIND' || method === 'PUT') {
|
||||
// Forward request body for methods that need it.
|
||||
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||
// PROPFIND bodies are small XML and are read fully.
|
||||
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||
if (method === 'PROPFIND') {
|
||||
body = await request.arrayBuffer();
|
||||
} else if (method === 'PUT') {
|
||||
body = request.body;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
|
||||
headers: upstreamHeaders,
|
||||
body,
|
||||
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
|
||||
if (method === 'GET') {
|
||||
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
|
||||
status: response.status,
|
||||
});
|
||||
} 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' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -208,6 +208,15 @@ body {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.email-content-text a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dark .email-content-text a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
|
||||
+19
-2
@@ -1,3 +1,20 @@
|
||||
<?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" 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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||
<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 { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -20,6 +22,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
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 } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -36,6 +46,14 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<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 && (
|
||||
<meta name="parent-origin" content={parentOrigin} />
|
||||
)}
|
||||
@@ -63,7 +81,9 @@ export default async function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||
const description =
|
||||
process.env.APP_DESCRIPTION ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||
|
||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
]
|
||||
: [
|
||||
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
];
|
||||
|
||||
return {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
description,
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
theme_color: themeColor,
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||
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 { cn } from "@/lib/utils";
|
||||
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 type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
timeFormat = "24h",
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
@@ -49,7 +51,7 @@ export function CalendarAgendaView({
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const sorted = [...events].sort((a, b) =>
|
||||
parseISO(a.start).getTime() - parseISO(b.start).getTime()
|
||||
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||
);
|
||||
|
||||
const groups: DayGroup[] = [];
|
||||
@@ -144,9 +146,9 @@ export function CalendarAgendaView({
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = parseISO(ev.start);
|
||||
const start = getEventStartDate(ev);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
const end = new Date(start.getTime() + durMin * 60000);
|
||||
const end = getEventEndDate(ev);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
|
||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onHoverLeave?.()}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { EventCard } from "./event-card";
|
||||
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 { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
@@ -37,6 +38,7 @@ export function CalendarDayView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
@@ -66,7 +68,7 @@ export function CalendarDayView({
|
||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||
if (!spansThisDay) return;
|
||||
|
||||
if (ev.showWithoutTime) allDay.push(ev);
|
||||
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||
else timed.push(ev);
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
@@ -127,10 +129,10 @@ export function CalendarDayView({
|
||||
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 (
|
||||
<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")}>
|
||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||
{isMobile
|
||||
@@ -157,6 +159,7 @@ export function CalendarDayView({
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -200,7 +203,7 @@ export function CalendarDayView({
|
||||
</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={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||
{HOURS.map((h) => (
|
||||
@@ -241,11 +244,9 @@ export function CalendarDayView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
@@ -266,6 +267,7 @@ export function CalendarDayView({
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
@@ -37,6 +38,7 @@ export function CalendarMonthView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
@@ -279,6 +281,7 @@ export function CalendarMonthView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
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 type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
@@ -18,6 +19,7 @@ interface CalendarSidebarPanelProps {
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export function CalendarSidebarPanel({
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
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>
|
||||
|
||||
{/* Subscription context menu on right-click */}
|
||||
@@ -173,6 +179,16 @@ export function CalendarSidebarPanel({
|
||||
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"
|
||||
>
|
||||
<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
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
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";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { EventCard } from "./event-card";
|
||||
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 { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
timeFormat?: "12h" | "24h";
|
||||
@@ -42,6 +43,7 @@ export function CalendarWeekView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
timeFormat = "24h",
|
||||
@@ -77,7 +79,7 @@ export function CalendarWeekView({
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
if (!ev.showWithoutTime) {
|
||||
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||
const arr = timed.get(key) || [];
|
||||
arr.push(ev);
|
||||
timed.set(key, arr);
|
||||
@@ -89,10 +91,18 @@ export function CalendarWeekView({
|
||||
return timed;
|
||||
}, [events]);
|
||||
|
||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
||||
const allDaySegments = useMemo(() => {
|
||||
const explicitAllDay = buildWeekSegmentsRaw(
|
||||
events.filter((event) => event.showWithoutTime),
|
||||
weekDays,
|
||||
), [events, weekDays]);
|
||||
);
|
||||
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||
events.filter((event) => !event.showWithoutTime),
|
||||
weekDays,
|
||||
);
|
||||
|
||||
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||
}, [events, weekDays]);
|
||||
|
||||
const allDayRowCount = useMemo(() => {
|
||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
@@ -189,13 +199,13 @@ export function CalendarWeekView({
|
||||
<div
|
||||
ref={rootRef}
|
||||
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"
|
||||
)}
|
||||
role="grid"
|
||||
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={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")}
|
||||
@@ -234,6 +244,7 @@ export function CalendarWeekView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -321,7 +332,7 @@ export function CalendarWeekView({
|
||||
</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={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||
{HOURS.map((h) => (
|
||||
@@ -344,7 +355,7 @@ export function CalendarWeekView({
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = timedEvents.get(key) || [];
|
||||
const todayCol = isToday(day);
|
||||
const layouted = layoutOverlappingEvents(dayEvents);
|
||||
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -371,11 +382,9 @@ export function CalendarWeekView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
@@ -396,6 +405,7 @@ export function CalendarWeekView({
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface EventCardProps {
|
||||
@@ -16,6 +17,7 @@ interface EventCardProps {
|
||||
onClick?: (anchorRect: DOMRect) => void;
|
||||
onMouseEnter?: (anchorRect: DOMRect) => void;
|
||||
onMouseLeave?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
isSelected?: boolean;
|
||||
draggable?: boolean;
|
||||
continuesBefore?: boolean;
|
||||
@@ -64,18 +66,18 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
const startDate = parseISO(event.start);
|
||||
const startDate = getEventStartDate(event);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
|
||||
const calendarName = calendar?.name || "";
|
||||
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 ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
@@ -108,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"aria-roledescription": "draggable event",
|
||||
} : {};
|
||||
|
||||
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
|
||||
|
||||
if (variant === "chip") {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onMouseLeave?.()}
|
||||
onContextMenu={handleContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
@@ -141,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onMouseLeave?.()}
|
||||
onContextMenu={handleContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
@@ -171,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onMouseLeave?.()}
|
||||
onContextMenu={handleContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
data-calendar-event
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Pencil,
|
||||
Copy,
|
||||
Download,
|
||||
ClipboardCopy,
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EventContextMenuProps {
|
||||
event: CalendarEvent;
|
||||
position: Position;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onExportICS: () => void;
|
||||
onCopyTitle: () => void;
|
||||
onCopyMeetingLink?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function EventContextMenu({
|
||||
event,
|
||||
position,
|
||||
isOpen,
|
||||
onClose,
|
||||
menuRef,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onExportICS,
|
||||
onCopyTitle,
|
||||
onCopyMeetingLink,
|
||||
onDelete,
|
||||
}: EventContextMenuProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
fn();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const hasMeetingLink = !!(
|
||||
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
|
||||
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("events.export_ics")}
|
||||
onClick={handle(onExportICS)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("events.copy_title")}
|
||||
onClick={handle(onCopyTitle)}
|
||||
/>
|
||||
{hasMeetingLink && onCopyMeetingLink && (
|
||||
<ContextMenuItem
|
||||
icon={LinkIcon}
|
||||
label={t("events.copy_link")}
|
||||
onClick={handle(onCopyMeetingLink)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("events.delete")}
|
||||
onClick={handle(onDelete)}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
|
||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.alerts) return null;
|
||||
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;
|
||||
if (offset === "PT0S") return t("alerts.at_time");
|
||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||
|
||||
const color = getEventColor(event, calendar);
|
||||
const startDate = parseISO(event.start);
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
const endDate = getEventEndDate(event);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput } from "./participant-input";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -159,7 +161,7 @@ export function EventModal({
|
||||
}, [event, existingParticipants]);
|
||||
|
||||
const getInitialStart = (): Date => {
|
||||
if (event?.start) return parseISO(event.start);
|
||||
if (event?.start) return getEventStartDate(event);
|
||||
if (defaultDate) {
|
||||
const d = new Date(defaultDate);
|
||||
if (defaultEndDate) return d;
|
||||
@@ -177,9 +179,7 @@ export function EventModal({
|
||||
if (event.showWithoutTime) {
|
||||
return getEventDisplayEndDate(event);
|
||||
}
|
||||
const s = parseISO(event.start);
|
||||
const dur = parseDuration(event.duration);
|
||||
return new Date(s.getTime() + dur * 60000);
|
||||
return getEventEndDate(event);
|
||||
}
|
||||
if (defaultEndDate) return new Date(defaultEndDate);
|
||||
return addHours(getInitialStart(), 1);
|
||||
@@ -190,6 +190,9 @@ export function EventModal({
|
||||
const [location, setLocation] = useState(
|
||||
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 [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||
@@ -230,6 +233,7 @@ export function EventModal({
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
@@ -261,6 +265,9 @@ export function EventModal({
|
||||
if (!trimmedTitle || isSaving) return;
|
||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||
|
||||
const pendingAttendee = participantInputRef.current?.flush() ?? null;
|
||||
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
|
||||
|
||||
const startStr = allDay
|
||||
? `${startDate}T00:00:00`
|
||||
: `${startDate}T${startTime}:00`;
|
||||
@@ -298,6 +305,10 @@ export function EventModal({
|
||||
privacy: "public",
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
data.uid = generateUUID();
|
||||
}
|
||||
|
||||
if (location.trim()) {
|
||||
data.locations = {
|
||||
loc1: {
|
||||
@@ -315,6 +326,20 @@ export function EventModal({
|
||||
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") {
|
||||
data.recurrenceRules = [{
|
||||
"@type": "RecurrenceRule",
|
||||
@@ -356,25 +381,27 @@ export function EventModal({
|
||||
data.alerts = null;
|
||||
}
|
||||
|
||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
||||
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||
const organizerEmail = currentUserEmails[0];
|
||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||
data.participants = buildParticipantMap(
|
||||
{ name: organizerName, email: organizerEmail },
|
||||
attendees
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
} else if (attendees.length === 0 && event?.participants) {
|
||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
data.replyTo = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling);
|
||||
} finally {
|
||||
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']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -384,11 +411,9 @@ export function EventModal({
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!event || !onDuplicate) return;
|
||||
const start = parseISO(event.start);
|
||||
const start = getEventStartDate(event);
|
||||
const newStart = addDays(start, 1);
|
||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const newUid = generateUUID();
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: newUid,
|
||||
title: event.title,
|
||||
@@ -403,6 +428,7 @@ export function EventModal({
|
||||
privacy: event.privacy,
|
||||
};
|
||||
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.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
@@ -456,9 +482,9 @@ export function EventModal({
|
||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||
|
||||
if (isAttendeeMode && event) {
|
||||
const startD = parseISO(event.start);
|
||||
const startD = getEventStartDate(event);
|
||||
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 participants = getParticipantList(event);
|
||||
|
||||
@@ -567,9 +593,9 @@ export function EventModal({
|
||||
|
||||
// View mode: read-only display of event details with Edit button
|
||||
if (mode === "view" && event) {
|
||||
const startD = parseISO(event.start);
|
||||
const startD = getEventStartDate(event);
|
||||
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 virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||
const viewParticipants = getParticipantList(event);
|
||||
@@ -780,6 +806,39 @@ export function EventModal({
|
||||
/>
|
||||
</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>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -788,6 +847,7 @@ export function EventModal({
|
||||
</span>
|
||||
</label>
|
||||
<ParticipantInput
|
||||
ref={participantInputRef}
|
||||
participants={attendees}
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
@@ -17,7 +19,7 @@ interface ICalImportModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
@@ -125,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
setIsParsing(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/fetch-ical", {
|
||||
const response = await apiFetch("/api/fetch-ical", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: trimmed }),
|
||||
@@ -195,7 +197,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||
if (!event.start) return "";
|
||||
try {
|
||||
const date = parseISO(event.start);
|
||||
const date = getEventStartDate(event as CalendarEvent);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
return event.showWithoutTime
|
||||
? format(date, "MMM d, yyyy")
|
||||
|
||||
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, Globe } from "lucide-react";
|
||||
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 { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalSubscriptionModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
editSubscription?: ICalSubscription;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||
const t = useTranslations("calendar.subscription");
|
||||
const tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
||||
const isEdit = !!editSubscription;
|
||||
|
||||
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -49,6 +53,16 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isEdit && editSubscription) {
|
||||
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||
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();
|
||||
} else {
|
||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||
if (subscription) {
|
||||
toast.success(t("success", { name: name.trim() }));
|
||||
@@ -56,8 +70,9 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
||||
} else {
|
||||
setError(t("error"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError(t("error"));
|
||||
setError(isEdit ? t("update_error") : t("error"));
|
||||
} finally {
|
||||
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 gap-2">
|
||||
<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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||
isSameDay, isSameMonth, isToday, format, parseISO,
|
||||
isSameDay, isSameMonth, isToday, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
@@ -54,7 +55,14 @@ export function MiniCalendar({
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
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;
|
||||
}, [events]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ParticipantInputHandle {
|
||||
flush: () => Participant | null;
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
||||
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
|
||||
const t = useTranslations("calendar.participants");
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||
@@ -86,8 +90,28 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}, []);
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
}, 200);
|
||||
}, [query, addParticipant]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
flush: () => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
|
||||
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
|
||||
const p = { name: "", email: trimmed };
|
||||
onAdd(p);
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
return p;
|
||||
},
|
||||
}), [query, participants, onAdd]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -160,4 +184,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ const defaultProps = {
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
onEditContact: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
onAddContactToGroup: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ContactList', () => {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Mail, CalendarDays, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
const EVENT_LIMIT = 5;
|
||||
const EVENT_LOOKAHEAD_DAYS = 365;
|
||||
|
||||
interface ContactActivityProps {
|
||||
contact: ContactCard;
|
||||
}
|
||||
|
||||
function getContactEmails(contact: ContactCard): string[] {
|
||||
if (!contact.emails) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const e of Object.values(contact.emails)) {
|
||||
const addr = e.address?.trim().toLowerCase();
|
||||
if (addr && !seen.has(addr)) {
|
||||
seen.add(addr);
|
||||
result.push(addr);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
for (const addr of addresses) {
|
||||
conditions.push({ from: addr });
|
||||
conditions.push({ to: addr });
|
||||
}
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return { operator: "OR", conditions };
|
||||
}
|
||||
|
||||
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||
if (!event.participants) return false;
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const email = p.email?.trim().toLowerCase();
|
||||
if (email && addresses.has(email)) return true;
|
||||
if (p.sendTo) {
|
||||
for (const target of Object.values(p.sendTo)) {
|
||||
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.organizerCalendarAddress) {
|
||||
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
|
||||
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
|
||||
if (addresses.has(org)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
const t = useTranslations("contacts.activity");
|
||||
const format = useFormatter();
|
||||
const router = useRouter();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const selectEmail = useEmailStore((s) => s.selectEmail);
|
||||
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
|
||||
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
|
||||
|
||||
const [emails, setEmails] = useState<Email[] | null>(null);
|
||||
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||
const [emailsLoading, setEmailsLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [emailsError, setEmailsError] = useState(false);
|
||||
const [eventsError, setEventsError] = useState(false);
|
||||
|
||||
const addresses = getContactEmails(contact);
|
||||
const addressKey = addresses.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || addresses.length === 0) {
|
||||
setEmails([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEmailsLoading(true);
|
||||
setEmailsError(false);
|
||||
client
|
||||
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setEmails(res.emails);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEmailsError(true);
|
||||
setEmails([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEmailsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || addresses.length === 0) {
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEventsLoading(true);
|
||||
setEventsError(false);
|
||||
const now = new Date();
|
||||
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
|
||||
const addrSet = new Set(addresses);
|
||||
client
|
||||
.queryAllCalendarEvents(
|
||||
{ after: now.toISOString(), before: before.toISOString() },
|
||||
[{ property: "start", isAscending: true }],
|
||||
500,
|
||||
)
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
const matching = all
|
||||
.filter((e) => eventInvolvesContact(e, addrSet))
|
||||
.sort((a, b) => a.start.localeCompare(b.start))
|
||||
.slice(0, EVENT_LIMIT);
|
||||
setEvents(matching);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEventsError(true);
|
||||
setEvents([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEventsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (addresses.length === 0) return null;
|
||||
|
||||
const handleOpenEmail = (email: Email) => {
|
||||
selectEmail(email);
|
||||
router.push("/mail");
|
||||
};
|
||||
|
||||
const handleOpenEvent = (event: CalendarEvent) => {
|
||||
const start = new Date(event.start);
|
||||
if (!isNaN(start.getTime())) setSelectedDate(start);
|
||||
setSelectedEventId(event.id);
|
||||
router.push("/calendar");
|
||||
};
|
||||
|
||||
const formatEmailDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return format.dateTime(d, sameYear
|
||||
? { month: "short", day: "numeric" }
|
||||
: { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatEventDate = (event: CalendarEvent) => {
|
||||
const d = new Date(event.start);
|
||||
if (isNaN(d.getTime())) return event.start;
|
||||
if (event.showWithoutTime) {
|
||||
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
return format.dateTime(d, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivitySection icon={Mail} title={t("recent_emails")}>
|
||||
{emailsLoading ? (
|
||||
<LoadingRow />
|
||||
) : emailsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
|
||||
) : (
|
||||
emails.map((email) => (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{email.subject || t("no_subject")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEmailDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{email.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.preview}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ActivitySection>
|
||||
|
||||
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
|
||||
{eventsLoading ? (
|
||||
<LoadingRow />
|
||||
) : eventsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{event.title || t("no_title")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEventDate(event)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ActivitySection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitySection({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Eye,
|
||||
Pencil,
|
||||
Mail,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
Users,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContactContextMenuProps {
|
||||
contact: ContactCard;
|
||||
position: Position;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isMultiSelect?: boolean;
|
||||
selectedCount?: number;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
}
|
||||
|
||||
export function ContactContextMenu({
|
||||
contact,
|
||||
position,
|
||||
isOpen,
|
||||
onClose,
|
||||
menuRef,
|
||||
isMultiSelect = false,
|
||||
selectedCount = 1,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddToGroup,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
}: ContactContextMenuProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
fn();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
if (!email) return;
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
const handleCopyEmail = async () => {
|
||||
if (!email) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
toast.success(t("detail.copied"));
|
||||
} catch {
|
||||
toast.error(t("detail.copy_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
exportContact(contact);
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
if (showBatchActions) {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuHeader>
|
||||
{t("bulk.selected", { count: selectedCount })}
|
||||
</ContextMenuHeader>
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("bulk.add_to_group")}
|
||||
onClick={handle(() => onBatchAddToGroup?.())}
|
||||
disabled={!onBatchAddToGroup}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("bulk.export")}
|
||||
onClick={handle(() => onBatchExport?.())}
|
||||
disabled={!onBatchExport}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("bulk.delete")}
|
||||
onClick={handle(() => onBatchDelete?.())}
|
||||
disabled={!onBatchDelete}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||
{email && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_email")}
|
||||
onClick={handle(handleCopyEmail)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
onClick={handle(handleExport)}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("context_menu.delete")}
|
||||
onClick={handle(onDelete)}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
@@ -87,7 +88,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
// data URI — extract base64 content
|
||||
// data URI - extract base64 content
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
@@ -178,7 +179,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{hasNickname && (
|
||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||
)}
|
||||
@@ -204,6 +205,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{/* Contact info */}
|
||||
{emails.length > 0 && (
|
||||
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
||||
@@ -278,7 +281,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -371,7 +374,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
{contact.speakToAs.pronouns && (() => {
|
||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||
return firstPronoun ? (
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
if (contact?.addressBookIds) {
|
||||
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
||||
if (ids.length > 0) {
|
||||
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
|
||||
// but we need the namespaced id to match addressBooks entries
|
||||
if (contact.isShared && contact.accountId) {
|
||||
return `${contact.accountId}:${ids[0]}`;
|
||||
}
|
||||
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
|
||||
// so we can use them directly to match addressBook entries
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
@@ -510,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name & Identity — full width */}
|
||||
{/* Name & Identity - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
@@ -578,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
setEmails(next);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
@@ -642,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
setPhones(next);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
@@ -680,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Addresses — full width */}
|
||||
{/* Addresses - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||
<div className="space-y-3">
|
||||
@@ -701,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
value={addr.context}
|
||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
@@ -836,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="masculine">{t("gender_male")}</option>
|
||||
<option value="feminine">{t("gender_female")}</option>
|
||||
<option value="other">{t("gender_other")}</option>
|
||||
@@ -869,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes — full width */}
|
||||
{/* Notes - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||
<textarea
|
||||
@@ -914,7 +911,6 @@ function CategoryComboBox({
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Parse current keywords from comma-separated string
|
||||
@@ -949,17 +945,6 @@ function CategoryComboBox({
|
||||
onChange(next);
|
||||
}, [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) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -973,7 +958,7 @@ function CategoryComboBox({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<div className="relative">
|
||||
{/* Keyword badges */}
|
||||
{currentKeywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
@@ -1001,6 +986,7 @@ function CategoryComboBox({
|
||||
value={inputValue}
|
||||
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||
/>
|
||||
@@ -1008,7 +994,7 @@ function CategoryComboBox({
|
||||
|
||||
{/* Dropdown */}
|
||||
{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 => (
|
||||
<button
|
||||
key={kw}
|
||||
|
||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
||||
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||
{cEmail && cName && (
|
||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||
)}
|
||||
|
||||
@@ -17,9 +17,10 @@ interface ContactListItemProps {
|
||||
selectedContactIds: Set<string>;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
|
||||
}
|
||||
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const org = contact.organizations
|
||||
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||
isSelected
|
||||
@@ -90,7 +92,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{name || email || "—"}
|
||||
{name || email || "-"}
|
||||
</div>
|
||||
{density !== 'extra-compact' && email && name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Sq
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
@@ -28,6 +30,9 @@ interface ContactListProps {
|
||||
onBulkDelete: () => void;
|
||||
onBulkAddToGroup: () => void;
|
||||
onBulkExport: () => void;
|
||||
onEditContact: (id: string) => void;
|
||||
onDeleteContact: (contact: ContactCard) => void;
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
@@ -47,9 +52,13 @@ export function ContactList({
|
||||
onBulkDelete,
|
||||
onBulkAddToGroup,
|
||||
onBulkExport,
|
||||
onEditContact,
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return contacts;
|
||||
@@ -210,11 +219,31 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={contextMenu.data}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
menuRef={menuRef}
|
||||
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedContactIds.size}
|
||||
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
||||
onEdit={() => onEditContact(contextMenu.data!.id)}
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onBatchExport={onBulkExport}
|
||||
onBatchAddToGroup={onBulkAddToGroup}
|
||||
onBatchDelete={onBulkDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
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 { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -58,10 +61,15 @@ export function ContactsSidebar({
|
||||
onDeleteGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const router = useRouter();
|
||||
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 [showMenu, setShowMenu] = useState(false);
|
||||
@@ -246,9 +254,10 @@ export function ContactsSidebar({
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -259,6 +268,18 @@ export function ContactsSidebar({
|
||||
{t("address_books.title")}
|
||||
</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) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -267,6 +288,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -362,6 +384,7 @@ export function ContactsSidebar({
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -372,9 +395,10 @@ export function ContactsSidebar({
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
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" />
|
||||
@@ -386,6 +410,18 @@ export function ContactsSidebar({
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</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) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -394,12 +430,53 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
{groupContextMenu.data && (
|
||||
<ContextMenu
|
||||
@@ -438,12 +515,14 @@ function CategoryItem({
|
||||
isActive,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
keyword: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -476,6 +555,7 @@ function CategoryItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -503,12 +583,14 @@ function AddressBookItem({
|
||||
contactCount,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
book: AddressBook;
|
||||
isActive: boolean;
|
||||
contactCount: number;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -541,6 +623,7 @@ function AddressBookItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inline preview text in focused mail layout', () => {
|
||||
useSettingsStore.setState({
|
||||
showPreview: true,
|
||||
mailLayout: 'focus',
|
||||
});
|
||||
const email = makeEmail({ preview: 'Inline preview content' });
|
||||
const { container } = render(<EmailListItem email={email} />);
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|| null;
|
||||
|
||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||
// Called after updating the local calendar event. Best-effort — if it
|
||||
// Called after updating the local calendar event. Best-effort - if it
|
||||
// fails we still report the RSVP as sent since the calendar was updated.
|
||||
const sendImipReply = async () => {
|
||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||
@@ -754,6 +754,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
<div className="lg:flex lg:gap-6">
|
||||
{/* Left: Event info */}
|
||||
<div className="lg:flex-1 space-y-2.5 min-w-0">
|
||||
{/* Event title */}
|
||||
{summary?.title && (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -772,7 +775,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
@@ -788,15 +791,30 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
<span>{summary.location}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
{summary?.organizer && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<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>{t('attendees', { count: summary.attendeeCount })}</span>
|
||||
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Info & actor messages on large screens */}
|
||||
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
|
||||
{bannerInfo && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||
)}
|
||||
{actorMessage && (
|
||||
<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>
|
||||
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</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 */}
|
||||
{proposedChanges.length > 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -29,13 +30,13 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return doc.body.textContent || '';
|
||||
}
|
||||
|
||||
export interface ComposerDraftData {
|
||||
@@ -65,7 +66,7 @@ interface EmailComposerProps {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
}) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
@@ -76,15 +77,30 @@ interface EmailComposerProps {
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: {
|
||||
from?: { email?: string; name?: string }[];
|
||||
replyToAddresses?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
bcc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
accountId?: string;
|
||||
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
type ComposerAttachment = {
|
||||
file?: File;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
blobId?: string;
|
||||
uploading?: boolean;
|
||||
error?: boolean;
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
@@ -100,17 +116,22 @@ export function EmailComposer({
|
||||
const tCommon = useTranslations('common');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
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
|
||||
const getInitialTo = () => {
|
||||
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') {
|
||||
const email = replyTo.from?.[0]?.email || "";
|
||||
return email ? email + ', ' : "";
|
||||
return replyTarget ? replyTarget + ', ' : "";
|
||||
} else if (mode === 'replyAll') {
|
||||
const from = replyTo.from?.[0]?.email || "";
|
||||
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 "";
|
||||
@@ -192,7 +213,22 @@ export function EmailComposer({
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||
// (matches the viewer's hideInlineImageAttachments logic).
|
||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||
@@ -207,6 +243,8 @@ export function EmailComposer({
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -220,12 +258,59 @@ export function EmailComposer({
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const attachmentWarningRef = useFocusTrap({
|
||||
isActive: showAttachmentWarning,
|
||||
onEscape: () => setShowAttachmentWarning(false),
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const { client } = useAuthStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || 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
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
@@ -266,7 +351,7 @@ export function EmailComposer({
|
||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
||||
|
||||
// Track initial values for dirty detection (captured once on first render)
|
||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
|
||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
|
||||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
||||
@@ -301,6 +386,17 @@ export function EmailComposer({
|
||||
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 [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
@@ -308,10 +404,26 @@ export function EmailComposer({
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = 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 ccDropdownRef = 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') => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
@@ -438,9 +550,16 @@ export function EmailComposer({
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (!client || files.length === 0) return;
|
||||
|
||||
const newAttachments = files.map(file => {
|
||||
const newAttachments: ComposerAttachment[] = files.map(file => {
|
||||
const controller = new AbortController();
|
||||
return { file, uploading: true, abortController: controller };
|
||||
return {
|
||||
file,
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
uploading: true,
|
||||
abortController: controller,
|
||||
};
|
||||
});
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
|
||||
@@ -475,11 +594,32 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
): Promise<{ src: string; cid: string } | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const [{ blobId }, dataUrl] = await Promise.all([
|
||||
client.uploadBlob(file),
|
||||
readAsDataUrl,
|
||||
]);
|
||||
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||
const cid = `${generateUUID()}@webmail`;
|
||||
inlineImagesRef.current.push({
|
||||
cid,
|
||||
blobId,
|
||||
type: file.type || 'application/octet-stream',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
});
|
||||
return { src: dataUrl, cid };
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
@@ -562,9 +702,9 @@ export function EmailComposer({
|
||||
.filter(att => att.blobId && !att.uploading)
|
||||
.map(att => ({
|
||||
blobId: att.blobId!,
|
||||
name: att.file.name,
|
||||
type: att.file.type,
|
||||
size: att.file.size,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size,
|
||||
}));
|
||||
|
||||
// Create a hash of current data to compare with last saved
|
||||
@@ -665,7 +805,42 @@ export function EmailComposer({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||
// references so recipient clients that strip data URIs can still render them.
|
||||
const rewriteInlineImages = (html: string): {
|
||||
html: string;
|
||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||
} => {
|
||||
const known = inlineImagesRef.current;
|
||||
if (known.length === 0) return { html, attachments: [] };
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
const used = new Map<string, typeof known[number]>();
|
||||
|
||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||
const cid = img.getAttribute('data-cid');
|
||||
if (!cid) return;
|
||||
const entry = known.find((e) => e.cid === cid);
|
||||
if (!entry) return;
|
||||
img.setAttribute('src', `cid:${cid}`);
|
||||
img.removeAttribute('data-cid');
|
||||
used.set(cid, entry);
|
||||
});
|
||||
|
||||
return {
|
||||
html: doc.body.innerHTML,
|
||||
attachments: Array.from(used.values()).map((e) => ({
|
||||
blobId: e.blobId,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
size: e.size,
|
||||
disposition: 'inline' as const,
|
||||
cid: e.cid,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSend = async (skipAttachmentCheck = false) => {
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
|
||||
@@ -684,6 +859,21 @@ export function EmailComposer({
|
||||
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;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -720,9 +910,11 @@ export function EmailComposer({
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
? undefined
|
||||
: `<div>${body}</div>${buildSignatureHtml()}`;
|
||||
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
|
||||
try {
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
@@ -751,19 +943,29 @@ export function EmailComposer({
|
||||
for (const att of attachments) {
|
||||
if (att.error || att.uploading) continue;
|
||||
let content: ArrayBuffer;
|
||||
if (att.file.size > 0) {
|
||||
if (att.file && att.file.size > 0) {
|
||||
content = await att.file.arrayBuffer();
|
||||
} else if (att.blobId && client) {
|
||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
|
||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
mimeAttachments.push({
|
||||
filename: att.file.name,
|
||||
contentType: att.file.type || 'application/octet-stream',
|
||||
filename: att.name,
|
||||
contentType: att.type || 'application/octet-stream',
|
||||
content,
|
||||
});
|
||||
}
|
||||
for (const inline of inlineAttachments) {
|
||||
if (!client) break;
|
||||
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||
mimeAttachments.push({
|
||||
filename: inline.name,
|
||||
contentType: inline.type,
|
||||
content,
|
||||
cid: inline.cid,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
const mimeBytes = buildMimeMessage({
|
||||
@@ -823,9 +1025,10 @@ export function EmailComposer({
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
const uploadedAttachments = attachments
|
||||
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
|
||||
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||
uploadedAttachments.push(...inlineAttachments);
|
||||
|
||||
await onSend?.({
|
||||
to: toAddresses,
|
||||
@@ -944,7 +1147,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
{/* Mobile: send button in header */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
@@ -1040,6 +1243,7 @@ export function EmailComposer({
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
validationError={validationErrors.to}
|
||||
validationMessage={t('validation.recipient_required')}
|
||||
onTab={focusSubject}
|
||||
/>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<Button
|
||||
@@ -1109,6 +1313,7 @@ export function EmailComposer({
|
||||
<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>
|
||||
<Input
|
||||
ref={subjectInputRef}
|
||||
type="text"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
@@ -1116,6 +1321,12 @@ export function EmailComposer({
|
||||
setSubject(e.target.value);
|
||||
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
focusBody();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"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"
|
||||
@@ -1128,6 +1339,7 @@ export function EmailComposer({
|
||||
{/* Body */}
|
||||
{plainTextMode ? (
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value);
|
||||
@@ -1142,6 +1354,7 @@ export function EmailComposer({
|
||||
aria-invalid={validationErrors.body || undefined}
|
||||
/>
|
||||
) : (
|
||||
<div ref={editorContainerRef}>
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
@@ -1152,6 +1365,7 @@ export function EmailComposer({
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plainTextMode ? (
|
||||
@@ -1194,9 +1408,9 @@ export function EmailComposer({
|
||||
) : (
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
({formatFileSize(att.file.size)})
|
||||
({formatFileSize(att.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
@@ -1298,7 +1512,7 @@ export function EmailComposer({
|
||||
{t('discard')}
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
@@ -1398,6 +1612,36 @@ export function EmailComposer({
|
||||
</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 && (
|
||||
<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"
|
||||
@@ -1483,6 +1727,7 @@ function RecipientChipInput({
|
||||
onInsertAutocomplete,
|
||||
validationError,
|
||||
validationMessage,
|
||||
onTab,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -1499,6 +1744,7 @@ function RecipientChipInput({
|
||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
}) {
|
||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const hasTrailingComma = value.trimEnd().endsWith(',');
|
||||
@@ -1532,7 +1778,18 @@ function RecipientChipInput({
|
||||
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
||||
if (e.key !== 'Tab') e.preventDefault();
|
||||
commitCurrentInput();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
onBatchArchive?: () => void;
|
||||
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
||||
onBatchMarkAsSpam?: () => void;
|
||||
onBatchUndoSpam?: () => void;
|
||||
@@ -89,17 +90,18 @@ const getMailboxIcon = (role?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
// Get all active label/color tag IDs from email keywords
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
return key.startsWith("$label:")
|
||||
? key.slice("$label:".length)
|
||||
: key.slice("$color:".length);
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return tags;
|
||||
};
|
||||
|
||||
export function EmailContextMenu({
|
||||
@@ -126,6 +128,7 @@ export function EmailContextMenu({
|
||||
onUndoSpam,
|
||||
onBatchMarkAsRead,
|
||||
onBatchDelete,
|
||||
onBatchArchive,
|
||||
onBatchMoveToMailbox,
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
@@ -137,7 +140,7 @@ export function EmailContextMenu({
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColor = getCurrentColor(email.keywords);
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
|
||||
@@ -234,8 +237,10 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Archive}
|
||||
label={t("archive")}
|
||||
onClick={() => handleAction(onArchive!)}
|
||||
disabled={!onArchive}
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
|
||||
}
|
||||
disabled={showBatchActions ? !onBatchArchive : !onArchive}
|
||||
/>
|
||||
|
||||
{/* Delete */}
|
||||
@@ -306,24 +311,27 @@ export function EmailContextMenu({
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
{colorOptions.map((option) => (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
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 && (
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
backgroundClassName?: string;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
|
||||
|
||||
export function EmailHoverActions({
|
||||
email,
|
||||
backgroundClassName = "bg-muted",
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
@@ -77,6 +79,7 @@ export function EmailHoverActions({
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const hoverBackgroundClassName = backgroundClassName;
|
||||
|
||||
if (hoverActions.length === 0) return null;
|
||||
|
||||
@@ -91,7 +94,7 @@ export function EmailHoverActions({
|
||||
onToggleStar?.();
|
||||
break;
|
||||
case "markRead":
|
||||
onMarkAsRead?.(!isUnread);
|
||||
onMarkAsRead?.(isUnread);
|
||||
break;
|
||||
case "archive":
|
||||
onArchive?.();
|
||||
@@ -144,7 +147,10 @@ export function EmailHoverActions({
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,8 +161,14 @@ export function EmailHoverActions({
|
||||
<div
|
||||
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 className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
@@ -32,9 +32,10 @@ interface EmailListItemProps {
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
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 density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
@@ -43,11 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
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
|
||||
const colorTagId = getEmailColorTag(email.keywords);
|
||||
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
@@ -114,15 +122,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
}
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<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)' }}
|
||||
>
|
||||
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"p-3 lg:p-1 rounded 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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -145,7 +157,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
@@ -156,6 +168,52 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('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" />}
|
||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
@@ -195,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
{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[keywordDef.color]?.bg || "bg-muted"
|
||||
KEYWORD_PALETTE[kd.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 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
|
||||
@@ -236,12 +294,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -103,11 +103,16 @@ export function EmailList({
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
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];
|
||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||
}, [density, showPreview]);
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threadGroups.length,
|
||||
@@ -171,7 +176,7 @@ export function EmailList({
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await batchDelete(client);
|
||||
await batchDelete(client, isInTrash);
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
@@ -216,15 +221,26 @@ export function EmailList({
|
||||
}
|
||||
}, [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 lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastVirtualItemIndex === undefined) return;
|
||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||
// 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]);
|
||||
|
||||
// Scroll to the thread group containing the selected email
|
||||
@@ -244,7 +260,7 @@ export function EmailList({
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [density, showPreview]);
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
@@ -480,6 +496,14 @@ export function EmailList({
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
if (!onArchive) return;
|
||||
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
|
||||
for (const email of selected) {
|
||||
await onArchive(email);
|
||||
}
|
||||
clearSelection();
|
||||
}}
|
||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||
onBatchMarkAsSpam={async () => {
|
||||
if (client) {
|
||||
|
||||
+258
-146
@@ -4,11 +4,11 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
||||
import ReactDOM from "react-dom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
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 {
|
||||
Reply,
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
HelpCircle,
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
@@ -84,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||
@@ -121,6 +122,7 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCompose?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
currentMailboxRole?: string;
|
||||
@@ -191,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
return 'Attachment';
|
||||
};
|
||||
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
return key.startsWith("$label:")
|
||||
? key.slice("$label:".length)
|
||||
: key.slice("$color:".length);
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return tags;
|
||||
};
|
||||
|
||||
// Helper function to format recipients with contextual display
|
||||
@@ -737,7 +740,7 @@ function ContactSidebarPanel({
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -865,6 +868,7 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCompose,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
currentMailboxRole,
|
||||
@@ -884,11 +888,17 @@ export function EmailViewer({
|
||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
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 toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
@@ -906,7 +916,7 @@ export function EmailViewer({
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode } = useAuthStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -928,7 +938,8 @@ export function EmailViewer({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
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
|
||||
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
||||
@@ -960,9 +971,9 @@ export function EmailViewer({
|
||||
|
||||
// Ensure S/MIME key records are loaded from IndexedDB
|
||||
useLayoutEffect(() => {
|
||||
smimeStore.load();
|
||||
smimeStore.load(activeAccountId ?? undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [activeAccountId]);
|
||||
|
||||
// Build mailbox tree for move-to dropdown
|
||||
const moveTargetIds = useMemo(() => new Set(
|
||||
@@ -1147,9 +1158,27 @@ export function EmailViewer({
|
||||
}
|
||||
);
|
||||
|
||||
const autoMarkedEmailRef = useRef<string | null>(null);
|
||||
|
||||
// Reset auto-mark tracking when email changes
|
||||
useEffect(() => {
|
||||
autoMarkedEmailRef.current = null;
|
||||
}, [email?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1162,17 +1191,18 @@ export function EmailViewer({
|
||||
|
||||
// Instant mark
|
||||
if (markAsReadDelay === 0) {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
onMarkAsRead(email.id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed mark
|
||||
const timeout = setTimeout(() => {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
onMarkAsRead(email.id, true);
|
||||
}, markAsReadDelay);
|
||||
|
||||
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]);
|
||||
|
||||
// Reset external content permission and quick reply when email changes
|
||||
@@ -1540,8 +1570,25 @@ export function EmailViewer({
|
||||
|
||||
if (detection.type === 'enveloped-data') {
|
||||
// Encrypted message
|
||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
||||
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||
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 {
|
||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||
let lastError: unknown = null;
|
||||
@@ -1552,6 +1599,7 @@ export function EmailViewer({
|
||||
cmsBytes: candidate.normalized,
|
||||
keyRecords,
|
||||
unlockedKeys: unlockedDecryptionKeys,
|
||||
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||
});
|
||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||
source: candidate.source,
|
||||
@@ -1597,7 +1645,7 @@ export function EmailViewer({
|
||||
// Check if inner content is also signed
|
||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||
if (nestedSignedData) {
|
||||
// Nested sign-then-encrypt — verify inner signature
|
||||
// Nested sign-then-encrypt - verify inner signature
|
||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||
source: nestedSignedData.source,
|
||||
@@ -1633,8 +1681,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
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) {
|
||||
smimeError('[S/MIME] nested signature verify failed:', {
|
||||
@@ -1672,10 +1727,12 @@ export function EmailViewer({
|
||||
decryptionError: 'locked',
|
||||
});
|
||||
} else {
|
||||
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||
setSmimeStatus({
|
||||
isSigned: false,
|
||||
isEncrypted: true,
|
||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
||||
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1737,8 +1794,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
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) {
|
||||
if (cancelled) return;
|
||||
@@ -1771,6 +1835,7 @@ export function EmailViewer({
|
||||
smimeStore.autoImportSignerCerts,
|
||||
smimeStore.keyRecords,
|
||||
smimeStore.unlockedDecryptionKeys,
|
||||
smimeStore.unlockedLegacyDecryptionKeys,
|
||||
smimeStore,
|
||||
]);
|
||||
|
||||
@@ -1780,61 +1845,61 @@ export function EmailViewer({
|
||||
|
||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||
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;
|
||||
}
|
||||
|
||||
debug.group('TNEF Processing');
|
||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||
debug.group('TNEF Processing', 'email');
|
||||
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
|
||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
||||
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||
let hasRealHtmlBody = !!htmlValue;
|
||||
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
||||
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) {
|
||||
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 {
|
||||
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;
|
||||
|
||||
async function processTnef() {
|
||||
try {
|
||||
debug.time('TNEF fetch blob');
|
||||
debug.time('TNEF fetch blob', 'email');
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||
debug.timeEnd('TNEF fetch blob');
|
||||
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
debug.timeEnd('TNEF fetch blob', 'email');
|
||||
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
|
||||
if (cancelled) {
|
||||
debug.log('TNEF: Processing cancelled after fetch');
|
||||
debug.log('email', 'TNEF: Processing cancelled after fetch');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
const tnefData = new Uint8Array(blobBytes);
|
||||
debug.time('TNEF parse');
|
||||
debug.time('TNEF parse', 'email');
|
||||
const parsed = parseTnef(tnefData);
|
||||
debug.timeEnd('TNEF parse');
|
||||
debug.timeEnd('TNEF parse', 'email');
|
||||
|
||||
if (cancelled) {
|
||||
debug.log('TNEF: Processing cancelled after parse');
|
||||
debug.log('email', 'TNEF: Processing cancelled after parse');
|
||||
debug.groupEnd();
|
||||
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) {
|
||||
setTnefHtml(parsed.htmlBody);
|
||||
@@ -1844,11 +1909,11 @@ export function EmailViewer({
|
||||
}
|
||||
if (parsed.attachments.length > 0) {
|
||||
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) {
|
||||
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();
|
||||
@@ -1886,13 +1951,13 @@ export function EmailViewer({
|
||||
const hasRealText = !!textValue;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
debug.group('Embedded RFC822 Unwrapping');
|
||||
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||
debug.log('Outer email body is empty, will unwrap embedded email');
|
||||
debug.group('Embedded RFC822 Unwrapping', 'email');
|
||||
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -1901,7 +1966,7 @@ export function EmailViewer({
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
if (blobBytes.byteLength === 0) {
|
||||
debug.warn('Embedded RFC822: Fetched blob is empty');
|
||||
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
@@ -1911,7 +1976,7 @@ export function EmailViewer({
|
||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||
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)',
|
||||
', attachments:', parsed.attachments?.length ?? 0);
|
||||
|
||||
@@ -1923,7 +1988,7 @@ export function EmailViewer({
|
||||
}
|
||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||
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 + ')'
|
||||
).join(', '));
|
||||
}
|
||||
@@ -2010,7 +2075,9 @@ export function EmailViewer({
|
||||
|
||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||
if (smimeDecryptedAttachments.length > 0) {
|
||||
return smimeDecryptedAttachments.map((attachment, index) => ({
|
||||
return smimeDecryptedAttachments
|
||||
.filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
|
||||
.map((attachment, index) => ({
|
||||
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
||||
name: attachment.filename,
|
||||
type: attachment.mimeType || 'application/octet-stream',
|
||||
@@ -2020,11 +2087,18 @@ export function EmailViewer({
|
||||
}));
|
||||
}
|
||||
|
||||
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
|
||||
const jmapAttachments = (email?.attachments ?? [])
|
||||
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
||||
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||
// Hide inline cid-referenced images when the user has opted to keep them
|
||||
// out of the attachment list (default on): these are embedded in the body.
|
||||
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map((attachment, index) => ({
|
||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||
name: attachment.name || null,
|
||||
@@ -2055,7 +2129,7 @@ export function EmailViewer({
|
||||
}));
|
||||
|
||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
|
||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
|
||||
|
||||
// Generate email source for viewing
|
||||
const generateEmailSource = (email: Email): string => {
|
||||
@@ -2250,9 +2324,11 @@ export function EmailViewer({
|
||||
// Use shared sanitization config as base (more secure)
|
||||
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 senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
: false;
|
||||
|
||||
// Block external content based on policy:
|
||||
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
||||
@@ -2327,16 +2403,8 @@ export function EmailViewer({
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||
|
||||
// Convert plain text to HTML with proper formatting
|
||||
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
|
||||
const htmlFromText = textContent
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
|
||||
return {
|
||||
html: htmlFromText,
|
||||
html: plainTextToSafeHtml(textContent),
|
||||
isHtml: false
|
||||
};
|
||||
}
|
||||
@@ -2359,7 +2427,7 @@ export function EmailViewer({
|
||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||
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
|
||||
const effectiveEmailContent = useMemo(() => {
|
||||
@@ -2374,12 +2442,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (smimeDecryptedText) {
|
||||
const htmlFromText = smimeDecryptedText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return { html: htmlFromText, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
@@ -2387,12 +2450,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (tnefText) {
|
||||
const htmlFromText = tnefText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return { html: htmlFromText, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
@@ -2400,12 +2458,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
const htmlFromText = embeddedEmailText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return { html: htmlFromText, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
|
||||
}
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
@@ -2526,14 +2579,21 @@ export function EmailViewer({
|
||||
|
||||
// If email has native dark mode, let it handle its own theming
|
||||
// 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 ? `
|
||||
html { background: #1a1a1a; }
|
||||
body { filter: invert(1) hue-rotate(180deg); }
|
||||
img, video, picture, svg, canvas, object, embed,
|
||||
[style*="background-image"], [style*="background:"],
|
||||
[background], [bgcolor],
|
||||
td[background], table[background],
|
||||
img[src], input[type="image"] {
|
||||
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
[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);
|
||||
}
|
||||
` : '';
|
||||
@@ -2572,11 +2632,35 @@ export function EmailViewer({
|
||||
a.setAttribute('target', '_blank');
|
||||
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 {
|
||||
// Cross-origin restrictions - iframe will still display content
|
||||
}
|
||||
}, []);
|
||||
}, [isDark, emailHasNativeDarkMode]);
|
||||
|
||||
// Export email as .eml file
|
||||
const handleExportEmail = async () => {
|
||||
@@ -2775,6 +2859,12 @@ export function EmailViewer({
|
||||
</div>
|
||||
<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>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2790,7 +2880,7 @@ export function EmailViewer({
|
||||
<>
|
||||
{/* Left: Reply actions */}
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -2805,7 +2895,7 @@ export function EmailViewer({
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.edit_draft')}
|
||||
>
|
||||
@@ -2854,7 +2944,7 @@ export function EmailViewer({
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
{isLoading && (
|
||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||
@@ -2951,7 +3041,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||
</Button>
|
||||
|
||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
||||
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
@@ -2959,43 +3049,51 @@ export function EmailViewer({
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
"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')}
|
||||
>
|
||||
{(() => {
|
||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
{currentColors.length > 0 ? (
|
||||
<>
|
||||
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
|
||||
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</button>
|
||||
{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">
|
||||
{colorOptions.map((option) => (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
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" />}
|
||||
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -3012,7 +3110,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
||||
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -3034,7 +3132,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
||||
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3047,7 +3145,7 @@ export function EmailViewer({
|
||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Print — hidden on mobile, overflows to More menu */}
|
||||
{/* Print - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3061,7 +3159,7 @@ export function EmailViewer({
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||
</Button>
|
||||
|
||||
{/* View source — hidden on mobile, overflows to More menu */}
|
||||
{/* View source - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3089,7 +3187,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More menu — click-based */}
|
||||
{/* More menu - click-based */}
|
||||
<div ref={moreMenuRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -3135,7 +3233,7 @@ export function EmailViewer({
|
||||
<Archive className="w-4 h-4" />
|
||||
{t('archive')}
|
||||
</button>
|
||||
{/* Overflow: move to folder — submenu */}
|
||||
{/* Overflow: move to folder - submenu */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('move')}
|
||||
@@ -3187,7 +3285,7 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag — submenu */}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
@@ -3203,21 +3301,24 @@ export function EmailViewer({
|
||||
</button>
|
||||
{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">
|
||||
{colorOptions.map((option) => (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
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" />}
|
||||
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -3393,21 +3494,24 @@ export function EmailViewer({
|
||||
<>
|
||||
<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>
|
||||
{colorOptions.map((option) => (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
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" />}
|
||||
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<button
|
||||
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"
|
||||
@@ -3524,7 +3628,7 @@ export function EmailViewer({
|
||||
<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">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3553,14 +3657,18 @@ export function EmailViewer({
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
{/* Color tag dot */}
|
||||
{currentColor && (() => {
|
||||
const kw = emailKeywords.find(k => k.id === currentColor);
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||
) : null;
|
||||
})()}
|
||||
{/* Color tag dots */}
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||
return (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||
{t('important')}
|
||||
@@ -3914,7 +4022,7 @@ export function EmailViewer({
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||
Spam Score
|
||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-xs",
|
||||
@@ -4403,7 +4511,7 @@ export function EmailViewer({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<EditIcon className="w-3.5 h-3.5" />
|
||||
@@ -4430,7 +4538,7 @@ export function EmailViewer({
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation) && (
|
||||
<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">
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
@@ -4449,7 +4557,11 @@ export function EmailViewer({
|
||||
onClick={() => {
|
||||
const senderEmail = email.from?.[0]?.email;
|
||||
if (senderEmail) {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||
} else {
|
||||
addTrustedSender(senderEmail);
|
||||
}
|
||||
setAllowExternalContent(true);
|
||||
}
|
||||
}}
|
||||
@@ -4662,61 +4774,61 @@ export function EmailViewer({
|
||||
onClick={onNavigatePrev}
|
||||
disabled={!onNavigatePrev}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.previous')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
|
||||
</button>
|
||||
{isDraft && onEditDraft ? (
|
||||
<button
|
||||
onClick={onEditDraft}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||
onClick={() => onEditDraft()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
|
||||
aria-label={t('tooltips.edit_draft')}
|
||||
>
|
||||
<EditIcon className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onReply?.()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onReplyAll}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
|
||||
</button>
|
||||
</>)}
|
||||
<button
|
||||
onClick={onNavigateNext}
|
||||
disabled={!onNavigateNext}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.next')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -4797,7 +4909,7 @@ export function EmailViewer({
|
||||
if (client && supportsSync) {
|
||||
createContact(client, contactData).then(() => toast.success('Contact added'));
|
||||
} else {
|
||||
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
toast.success('Contact added');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
||||
email: string;
|
||||
/** Display label override (e.g. "me") */
|
||||
displayLabel?: string;
|
||||
/** Called when user clicks "View contact" — receives the contact and email */
|
||||
/** Called when user clicks "View contact" - receives the contact and email */
|
||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [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 = () => {
|
||||
if (onViewContact) {
|
||||
onViewContact(contact ?? null, email);
|
||||
|
||||
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
|
||||
alt: { default: null },
|
||||
title: { default: null },
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -31,10 +31,15 @@ import {
|
||||
Heading2,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface InlineImageUpload {
|
||||
src: string;
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
onImageUpload?: (file: File) => Promise<string | null>;
|
||||
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
@@ -118,12 +123,13 @@ export function RichTextEditor({
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
upload(file).then((result) => {
|
||||
if (result) {
|
||||
const { state } = view;
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
@@ -140,10 +146,10 @@ export function RichTextEditor({
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
upload(file).then((result) => {
|
||||
if (result) {
|
||||
const { state } = view;
|
||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||
const tr = state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
text: t('unlock_key_desc'),
|
||||
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 {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-4 h-4" />,
|
||||
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
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({
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
|
||||
@@ -84,6 +85,10 @@ export function ThreadConversationView({
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
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)
|
||||
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)' }}>
|
||||
{emails.map((email, index) => {
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
: false;
|
||||
return (
|
||||
<EmailCard
|
||||
key={email.id}
|
||||
@@ -175,7 +182,11 @@ export function ThreadConversationView({
|
||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||
onTrustSender={senderEmail ? () => {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||
} else {
|
||||
addTrustedSender(senderEmail);
|
||||
}
|
||||
toggleAllowExternal(email.id);
|
||||
} : undefined}
|
||||
onReply={onReply ? () => onReply(email) : undefined}
|
||||
@@ -226,6 +237,7 @@ function EmailCard({
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -408,12 +420,7 @@ function EmailCard({
|
||||
// Plain text fallback
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||
const htmlEscaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
||||
return { html: htmlEscaped, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,10 +545,14 @@ function EmailCard({
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
{(() => {
|
||||
const visibleAttachments = (email.attachments ?? []).filter(
|
||||
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
|
||||
);
|
||||
return visibleAttachments.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{email.attachments.map((attachment, idx) => {
|
||||
{visibleAttachments.map((attachment, idx) => {
|
||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
@@ -570,7 +581,8 @@ function EmailCard({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 flex gap-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user