Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f | ||
|
|
9207ec563c | ||
|
|
44e0e17203 | ||
|
|
b9c9901643 | ||
|
|
a201c1617b | ||
|
|
d24f402b0c | ||
|
|
5cfc905f10 | ||
|
|
4531cfe47c | ||
|
|
927a3b8b11 | ||
|
|
4ad8396adc | ||
|
|
6aaeb34272 | ||
|
|
ab57966a94 | ||
|
|
18d9b9adf6 | ||
|
|
ed311d79e3 | ||
|
|
457400ceee | ||
|
|
88d87be685 | ||
|
|
5ddb2acfc7 | ||
|
|
5f150f039d | ||
|
|
4f54f768e8 | ||
|
|
c24762c7a3 | ||
|
|
3e85e07363 | ||
|
|
facef97fcc | ||
|
|
89d580b90d | ||
|
|
734155c939 | ||
|
|
790d7084af | ||
|
|
60efb047d8 | ||
|
|
f9052eb23f |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:github.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
# Bulwark Webmail — Development Configuration
|
# Bulwark Webmail - Development Configuration
|
||||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||||
# No external mail server required — great for UI development and testing.
|
# No external mail server required - great for UI development and testing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.dev.example .env.local
|
# cp .env.dev.example .env.local
|
||||||
# npm run dev
|
# npm run dev
|
||||||
# Open http://localhost:3000 — log in with any username/password.
|
# Open http://localhost:3000 - log in with any username/password.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock JMAP Server
|
# Mock JMAP Server
|
||||||
|
|||||||
+61
-17
@@ -1,4 +1,4 @@
|
|||||||
# Bulwark Webmail — Production Configuration
|
# Bulwark Webmail - Production Configuration
|
||||||
# Copy this file to .env.local and fill in your values.
|
# Copy this file to .env.local and fill in your values.
|
||||||
# For development with the built-in mock server, see .env.dev.example instead.
|
# For development with the built-in mock server, see .env.dev.example instead.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
# JMAP Server (required)
|
# JMAP Server (required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# App name displayed in the UI
|
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
@@ -48,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# OAuth client secret (server-side only, never exposed to the browser)
|
# OAuth client secret (server-side only, never exposed to the browser)
|
||||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
# Alternatively, you can specify the path to a file containing the OAuth client secret.
|
||||||
|
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
|
||||||
|
|
||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
@@ -60,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Required for both "Remember me" and settings sync features.
|
# Required for both "Remember me" and settings sync features.
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
# SESSION_SECRET=your-secret-key-here
|
# SESSION_SECRET=your-secret-key-here
|
||||||
|
# Alternatively, you can specify the path to a file containing the session secret.
|
||||||
|
# SESSION_SECRET_FILE=/session-secret
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Settings Sync
|
# Settings Sync
|
||||||
@@ -72,7 +76,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, the working directory is /app, so the default resolves to
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
# /app/data/settings — mount a persistent volume there:
|
# /app/data/settings - mount a persistent volume there:
|
||||||
# volumes:
|
# volumes:
|
||||||
# - bulwark-settings:/app/data/settings
|
# - bulwark-settings:/app/data/settings
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
@@ -102,41 +106,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Branding (all optional)
|
# Branding (all optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Custom favicon for the browser tab.
|
# ---------------------------------------------------------------------------
|
||||||
|
# App identity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Short name for the app, used in contexts where space is limited
|
||||||
|
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||||
|
# APP_SHORT_NAME=Bulwark
|
||||||
|
|
||||||
|
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||||
|
# Defaults to a generic Bulwark description if not set.
|
||||||
|
# APP_DESCRIPTION=Your personal webmail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Icons & favicon
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Custom favicon shown in the browser tab.
|
||||||
# Supported formats: SVG (recommended), PNG, ICO.
|
# Supported formats: SVG (recommended), PNG, ICO.
|
||||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
# Can be an absolute URL or a path relative to the public/ directory.
|
|
||||||
# Defaults to the Bulwark favicon if not set.
|
# Defaults to the Bulwark favicon if not set.
|
||||||
# FAVICON_URL=/branding/my-favicon.svg
|
# FAVICON_URL=/branding/my-favicon.svg
|
||||||
|
|
||||||
# Custom logos for the sidebar (shown in the main app after login).
|
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||||
|
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||||
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
|
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||||
|
# PWA_ICON_URL=/branding/my-icon.svg
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PWA appearance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||||
|
# (address bar, status bar on Android). Default: #ffffff
|
||||||
|
# PWA_THEME_COLOR=#3b82f6
|
||||||
|
|
||||||
|
# Background color shown on the PWA splash screen while the app is loading.
|
||||||
|
# Should match your app's main background color. Default: #ffffff
|
||||||
|
# PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Logos shown in the sidebar (main app, after login).
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 24×24px, max 128×128px
|
# Recommended size: min 24×24px, max 128×128px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# If not set, no logo is shown in the sidebar.
|
# If not set, no logo is shown in the sidebar.
|
||||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||||
|
|
||||||
# Custom logo images for the login page.
|
# Logos shown on the login page.
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 32×32px, max 512×512px
|
# Recommended size: min 32×32px, max 512×512px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
#
|
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
|
||||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
|
|
||||||
# Company or organization name displayed above the version on the login page
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Company name shown above the version number on the login page.
|
||||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL for the imprint/legal notice link on the login page
|
# URL for the imprint / legal notice link on the login page.
|
||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
|
||||||
# URL for the privacy policy link on the login page
|
# URL for the privacy policy link on the login page.
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
|
||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -21,11 +21,23 @@ on:
|
|||||||
- ".github/workflows/docker-publish.yml"
|
- ".github/workflows/docker-publish.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image_name: ${{ steps.set.outputs.image_name }}
|
||||||
|
steps:
|
||||||
|
- name: Set image name
|
||||||
|
id: set
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: prepare
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -57,7 +69,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
|
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
@@ -66,7 +78,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -86,7 +98,7 @@ jobs:
|
|||||||
|
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: [prepare, build]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -113,17 +125,17 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value={{branch}}
|
type=raw,value=latest
|
||||||
type=sha,prefix={{branch}}-
|
type=sha
|
||||||
|
|
||||||
- name: Create manifest list and push
|
- name: Create manifest list and push
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
|
||||||
|
|
||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Publish Standalone Tarball on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build standalone
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
ARCH: ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
VERSION="${REF_NAME#v}"
|
||||||
|
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p bulwark-standalone
|
||||||
|
cp -r .next/standalone/. bulwark-standalone/
|
||||||
|
cp -r .next/static bulwark-standalone/.next/static
|
||||||
|
cp -r public bulwark-standalone/public
|
||||||
|
|
||||||
|
tar -czf "$TARBALL" bulwark-standalone/
|
||||||
|
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload release asset
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||||
|
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
|
||||||
|
|
||||||
|
- name: Upload artifact (workflow_dispatch)
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: standalone-${{ matrix.arch }}
|
||||||
|
path: ${{ env.TARBALL }}
|
||||||
|
retention-days: 7
|
||||||
+79
-2
@@ -1,13 +1,90 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.14 (2026-04-16)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- [@pr0ton11](https://github.com/pr0ton11)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||||
|
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||||
|
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||||
|
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||||
|
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||||
|
- **PWA**: Show app name and logo in install prompt
|
||||||
|
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||||
|
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||||
|
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||||
|
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||||
|
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||||
|
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||||
|
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||||
|
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||||
|
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||||
|
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||||
|
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||||
|
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||||
|
|
||||||
|
## 1.4.13 (2026-04-12)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
|
||||||
|
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
|
||||||
|
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
|
||||||
|
- **PWA**: Add "don't remind me again" option to install prompt
|
||||||
|
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
|
||||||
|
- **Plugins**: Add `onAvatarResolve` plugin hook
|
||||||
|
- **Docker**: Publish main and dev branches as separate GHCR packages
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Style links in plain text emails
|
||||||
|
- **Email**: Seed list history entry when app initializes on an email view
|
||||||
|
- **Email**: Remount composer on draft edit and preserve identity (#60)
|
||||||
|
- **Contacts**: Display contact names stored in `name.full` (#179)
|
||||||
|
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
|
||||||
|
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
|
||||||
|
- **Search**: Search all folders when filtering emails by tag (#175)
|
||||||
|
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
|
||||||
|
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
|
||||||
|
|
||||||
## 1.4.12 (2026-04-09)
|
## 1.4.12 (2026-04-09)
|
||||||
|
|
||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -403,7 +480,7 @@ Thank you for your donations:
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||||
|
|
||||||
## 1.1.1 (2026-02-28)
|
## 1.1.1 (2026-02-28)
|
||||||
@@ -413,7 +490,7 @@ Thank you for your donations:
|
|||||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||||
|
|
||||||
## 1.1.0 (2026-02-28)
|
## 1.1.0 (2026-02-28)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||||
|
|
||||||
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||||
|
|
||||||
**Calendar** — Month, week, day, and agenda views with event management
|
**Calendar** - Month, week, day, and agenda views with event management
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||||
|
|
||||||
**Contacts** — Contact management with groups and vCard support
|
**Contacts** - Contact management with groups and vCard support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||||
|
|
||||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||||
|
|
||||||
**Light mode** — Full theme support with intelligent color transformation
|
**Light mode** - Full theme support with intelligent color transformation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||||
|
|
||||||
**Settings** — Appearance, identities, filters, templates, and more
|
**Settings** - Appearance, identities, filters, templates, and more
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||||
|
|
||||||
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
@@ -94,34 +94,46 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
### Mail
|
### Mail
|
||||||
|
|
||||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||||
- **Draft auto-save** with discard confirmation
|
- **Unified mailbox** - view emails across all accounts in a single list
|
||||||
- **Attachments** — upload, download, and inline preview
|
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||||
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||||
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||||
- **Archive modes** — archive directly or organize archived mail by year or month
|
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||||
|
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||||
- **Print** emails directly from the viewer
|
- **Print** emails directly from the viewer
|
||||||
- **Answered/forwarded status icons** in email list and thread views
|
- **Answered/forwarded status icons** in email list and thread views
|
||||||
- **Color tags/labels** and star/unstar
|
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||||
|
- **Star/unstar** with configurable mark-as-read delay
|
||||||
- **Virtual scrolling** for large mailboxes
|
- **Virtual scrolling** for large mailboxes
|
||||||
- **Quick reply** from the viewer
|
- **Quick reply** from the viewer
|
||||||
- **Sender avatars** — favicon-based with negative caching for performance
|
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||||
|
- **Sender avatars** - favicon-based with negative caching for performance
|
||||||
- **Recipient popover** for quick contact interaction
|
- **Recipient popover** for quick contact interaction
|
||||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
- **Plain text composer mode** and auto-select reply identity
|
||||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
- **Reply-to addresses** support in the composer
|
||||||
- **Tag counts** — unread and total counts displayed in sidebar
|
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||||
|
- **message/rfc822 unwrapping** for embedded messages
|
||||||
|
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||||
|
- **Tag counts** - unread and total counts displayed in sidebar
|
||||||
|
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
|
|
||||||
- **Month, week, day, and agenda views** with mini-calendar sidebar
|
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
|
||||||
- **Event hover preview** popover with details
|
- **Event hover preview** popover with configurable details
|
||||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||||
- **iCalendar import** with preview and bulk create
|
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
- **iCal / webcal subscriptions** with editing and batch import
|
||||||
|
- **Birthday calendar** - auto-generated from contacts
|
||||||
|
- **Virtual locations** - video conference URLs as first-class event fields
|
||||||
|
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||||
|
- **Shared calendars** with visual grouping in the sidebar
|
||||||
|
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||||
- **Week numbers** in mini-calendar sidebar
|
- **Week numbers** in mini-calendar sidebar
|
||||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||||
- **Real-time sync** via JMAP push
|
- **Real-time sync** via JMAP push
|
||||||
@@ -129,77 +141,103 @@ Built with Next.js and the JMAP protocol.
|
|||||||
### Contacts
|
### Contacts
|
||||||
|
|
||||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||||
|
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||||
|
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||||
- **Contact groups** with group expansion and member management
|
- **Contact groups** with group expansion and member management
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||||
|
- **Trusted senders** stored in a dedicated JMAP address book
|
||||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||||
- **Bulk operations** — multi-select, delete, group add, export
|
- **Bulk operations** - multi-select, delete, group add, export
|
||||||
|
|
||||||
### Filters & Automation
|
### Filters & Automation
|
||||||
|
|
||||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||||
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||||
|
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||||
- **Raw Sieve editor** with syntax validation
|
- **Raw Sieve editor** with syntax validation
|
||||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||||
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||||
- **Upload and download** files with progress tracking and folder upload support
|
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||||
|
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||||
- **Grid and list views** with sorting by name, size, or date
|
- **Grid and list views** with sorting by name, size, or date
|
||||||
- **Clipboard operations** — cut, copy, paste, duplicate files
|
- **Clipboard operations** - cut, copy, paste, duplicate files
|
||||||
- **File preview** for images, text, audio, video, and more
|
- **File preview** for images, text, audio, video, and more
|
||||||
- **Favorites and recent files** for quick access
|
- **Favorites and recent files** for quick access
|
||||||
- **Bulk operations** — multi-select, delete, move, download
|
- **Bulk operations** - multi-select, delete, move, download
|
||||||
|
|
||||||
### Security & Privacy
|
### Security & Privacy
|
||||||
|
|
||||||
- **External content blocked** by default — trusted senders list for auto-load
|
- **External content blocked** by default - trusted senders list for auto-load
|
||||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||||
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
- **SPF/DKIM/DMARC** status indicators
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
|
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||||
- **TOTP two-factor authentication**
|
- **TOTP two-factor authentication**
|
||||||
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||||
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||||
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||||
|
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
- **Newsletter unsubscribe** (RFC 2369)
|
||||||
|
|
||||||
### Interface
|
### Interface
|
||||||
|
|
||||||
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||||
- **Dark and light themes** with intelligent email color transformation
|
- **Dark and light themes** with intelligent email color transformation
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||||
- **Keyboard shortcuts** — full navigation without a mouse
|
- **Keyboard shortcuts** - full navigation without a mouse
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||||
- **Interactive guided tour** — onboarding walkthrough for new users
|
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||||
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||||
- **Storage quota** display
|
- **Storage quota** display
|
||||||
- **Shared folders** — multi-account access
|
- **Version badge** in settings
|
||||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
- **Focused mode** with proper viewport bounds
|
||||||
|
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
|
|
||||||
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||||
|
|
||||||
Automatic browser detection with persistent preference.
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
### Identity Management
|
### Identity Management
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
- **Multiple sender identities** with per-identity signatures
|
||||||
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||||
- **Identity badges** in viewer and email list
|
- **Identity badges** in viewer and email list
|
||||||
|
|
||||||
|
### Multi-Account
|
||||||
|
|
||||||
|
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||||
|
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||||
|
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||||
|
- **Shared folders** across accounts
|
||||||
|
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
|
### Admin & Extensibility
|
||||||
|
|
||||||
|
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||||
|
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||||
|
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||||
|
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||||
|
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
- **Automatic update check** - server logs when a newer release is available
|
||||||
|
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||||
|
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||||
|
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -217,7 +255,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local - set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local - set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -252,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server Listen Address</summary>
|
||||||
@@ -271,6 +309,7 @@ PORT=3000 # Default listen port
|
|||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
|
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -279,13 +318,79 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Remember Me</summary>
|
<summary>Remember Me & Settings Sync</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||||
|
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||||
|
|
||||||
|
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||||
|
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
|
||||||
```
|
```
|
||||||
|
|
||||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||||
|
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Custom JMAP Endpoint</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||||
|
```
|
||||||
|
|
||||||
|
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Branding & PWA</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=My Webmail
|
||||||
|
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||||
|
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||||
|
|
||||||
|
FAVICON_URL=/branding/favicon.svg
|
||||||
|
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
|
||||||
|
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
|
||||||
|
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
|
||||||
|
|
||||||
|
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||||
|
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||||
|
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||||
|
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||||
|
|
||||||
|
LOGIN_COMPANY_NAME=My Company
|
||||||
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Extension Directory</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Stalwart Integration & Logging</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
STALWART_FEATURES=true # Password change, sieve filters, etc.
|
||||||
|
STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
|
||||||
|
|
||||||
|
LOG_FORMAT=text # "text" or "json"
|
||||||
|
LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -319,7 +424,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
|||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
if (!state || state !== savedState) {
|
if (!state || state !== savedState) {
|
||||||
setError("invalid_state");
|
setError("invalid_state");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ export default function CalendarPage() {
|
|||||||
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
||||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
|
|
||||||
|
// Keep detailEvent in sync with store events (e.g. after update + refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (detailEvent) {
|
||||||
|
const updated = events.find(e => e.id === detailEvent.id);
|
||||||
|
if (updated && updated !== detailEvent) {
|
||||||
|
setDetailEvent(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [events, detailEvent]);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
@@ -231,8 +232,9 @@ export default function LoginPage() {
|
|||||||
const startServerSideSso = useCallback(async () => {
|
const startServerSideSso = useCallback(async () => {
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
try {
|
try {
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const res = await fetch('/api/auth/sso/start', {
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -253,7 +255,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
window.top!.location.href = authorize_url;
|
window.top!.location.href = authorize_url;
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restriction — fall back to current frame
|
// Cross-origin restriction - fall back to current frame
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -757,7 +759,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Dev mode — logging in as dev@localhost
|
Dev mode - logging in as dev@localhost
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : oauthOnly ? (
|
) : oauthOnly ? (
|
||||||
|
|||||||
+178
-40
@@ -9,11 +9,14 @@ import { EmailComposer } from "@/components/email/email-composer";
|
|||||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
@@ -61,6 +64,7 @@ export default function Home() {
|
|||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -79,6 +83,15 @@ export default function Home() {
|
|||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
useIdentitySync();
|
useIdentitySync();
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||||
|
|
||||||
|
// Load trusted senders address book when feature is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||||
|
loadTrustedSendersBook(client);
|
||||||
|
}
|
||||||
|
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRateLimited || !rateLimitUntil) {
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
@@ -144,8 +157,54 @@ export default function Home() {
|
|||||||
hasMoreEmails,
|
hasMoreEmails,
|
||||||
fetchTagCounts,
|
fetchTagCounts,
|
||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
|
isUnifiedView,
|
||||||
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
|
refreshUnifiedCounts,
|
||||||
|
exitUnifiedView,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const connectedAccountsSignature = useMemo(
|
||||||
|
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||||
|
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||||
|
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||||
|
const result: UnifiedAccountClient[] = [];
|
||||||
|
for (const account of connected) {
|
||||||
|
const accountClient = clients.get(account.id);
|
||||||
|
if (!accountClient) continue;
|
||||||
|
result.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountLabel: account.label || account.email,
|
||||||
|
client: accountClient,
|
||||||
|
mailboxes: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const populateUnifiedAccountMailboxes = useCallback(
|
||||||
|
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||||
|
const populated = await Promise.all(
|
||||||
|
list.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const mailboxes = await entry.client.getMailboxes();
|
||||||
|
return { ...entry, mailboxes };
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return populated;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Browser back / forward integration. The restore handler reads the
|
// Browser back / forward integration. The restore handler reads the
|
||||||
// latest values from a ref so we don't have to recreate the callback on
|
// 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).
|
// every render (and so the popstate listener is never stale).
|
||||||
@@ -197,7 +256,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Restore conversation thread (mobile only). We can clear it directly,
|
// Restore conversation thread (mobile only). We can clear it directly,
|
||||||
// but reopening requires the thread group; if the user pressed forward
|
// but reopening requires the thread group; if the user pressed forward
|
||||||
// to return to a thread, we silently skip — back navigation always works.
|
// to return to a thread, we silently skip - back navigation always works.
|
||||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||||
if (state.threadId === null) {
|
if (state.threadId === null) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
@@ -474,13 +533,30 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
||||||
|
|
||||||
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
|
// or the primary account's mailboxes change (a proxy for "something worth
|
||||||
|
// recounting happened").
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
if (built.length < 2) return;
|
||||||
|
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
});
|
||||||
|
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||||
|
|
||||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEmail || !client) return;
|
if (!selectedEmail || !client) return;
|
||||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||||
if (!selectedEmail.bodyValues) {
|
if (!selectedEmail.bodyValues) {
|
||||||
|
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||||
setLoadingEmail(false);
|
setLoadingEmail(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -642,6 +718,16 @@ export default function Home() {
|
|||||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Try to find the identity that matches the draft's from address to preserve it
|
||||||
|
const draftFromEmail = draft.from?.[0]?.email;
|
||||||
|
const matchedIdentity = draftFromEmail
|
||||||
|
? identities.find(id => id.email === draftFromEmail)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Increment session ID to force the composer to remount with fresh state,
|
||||||
|
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||||
|
setComposerSessionId(id => id + 1);
|
||||||
setPendingDraft({
|
setPendingDraft({
|
||||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
@@ -650,7 +736,7 @@ export default function Home() {
|
|||||||
body: htmlBody || bodyText,
|
body: htmlBody || bodyText,
|
||||||
showCc: (draft.cc?.length || 0) > 0,
|
showCc: (draft.cc?.length || 0) > 0,
|
||||||
showBcc: (draft.bcc?.length || 0) > 0,
|
showBcc: (draft.bcc?.length || 0) > 0,
|
||||||
selectedIdentityId: null,
|
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||||
subAddressTag: '',
|
subAddressTag: '',
|
||||||
mode: 'compose',
|
mode: 'compose',
|
||||||
draftId: draft.id,
|
draftId: draft.id,
|
||||||
@@ -672,8 +758,8 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
// Check if we're currently in the trash or junk folder
|
// Check if we're currently in the trash or junk folder
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
@@ -692,7 +778,7 @@ export default function Home() {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmail(client, selectedEmail.id, true);
|
await deleteEmail(client, emailToDelete.id, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to permanently delete email:", error);
|
console.error("Failed to permanently delete email:", error);
|
||||||
}
|
}
|
||||||
@@ -701,7 +787,7 @@ export default function Home() {
|
|||||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||||
if (trashMailbox) {
|
if (trashMailbox) {
|
||||||
try {
|
try {
|
||||||
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
|
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to move email to trash:", error);
|
console.error("Failed to move email to trash:", error);
|
||||||
}
|
}
|
||||||
@@ -740,7 +826,7 @@ export default function Home() {
|
|||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
// archiveMode === 'month' - find or create month subfolder under year
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
let monthMailbox = mailboxes.find(
|
let monthMailbox = mailboxes.find(
|
||||||
m => m.name === month && m.parentId === yearId
|
m => m.name === month && m.parentId === yearId
|
||||||
@@ -774,10 +860,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsSpam = async () => {
|
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToMark) return;
|
||||||
|
|
||||||
const emailId = selectedEmail.id;
|
const emailId = emailToMark.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await markAsSpam(client, emailId);
|
await markAsSpam(client, emailId);
|
||||||
@@ -805,11 +891,11 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoSpam = async () => {
|
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToRestore) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await undoSpam(client, selectedEmail.id);
|
await undoSpam(client, emailToRestore.id);
|
||||||
|
|
||||||
const toastInstance = (await import('sonner')).toast;
|
const toastInstance = (await import('sonner')).toast;
|
||||||
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
||||||
@@ -830,16 +916,22 @@ export default function Home() {
|
|||||||
|
|
||||||
const keywords = { ...email.keywords };
|
const keywords = { ...email.keywords };
|
||||||
|
|
||||||
// Remove old label and legacy color tags - set to false for JMAP to remove them
|
if (color === null) {
|
||||||
Object.keys(keywords).forEach(key => {
|
// Remove all label/color tags
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
Object.keys(keywords).forEach(key => {
|
||||||
keywords[key] = false;
|
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||||
|
keywords[key] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const jmapKey = `$label:${color}`;
|
||||||
|
if (keywords[jmapKey] === true) {
|
||||||
|
// Toggle off if already active
|
||||||
|
keywords[jmapKey] = false;
|
||||||
|
} else {
|
||||||
|
// Add the tag without disturbing others
|
||||||
|
keywords[jmapKey] = true;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Add new label tag if specified (using new $label: prefix)
|
|
||||||
if (color) {
|
|
||||||
keywords[`$label:${color}`] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update email keywords via JMAP
|
// Update email keywords via JMAP
|
||||||
@@ -859,6 +951,32 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMailboxSelect = async (mailboxId: string) => {
|
const handleMailboxSelect = async (mailboxId: string) => {
|
||||||
|
if (isUnifiedMailboxId(mailboxId)) {
|
||||||
|
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
selectMailbox(mailboxId);
|
||||||
|
selectEmail(null);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
const populated = await populateUnifiedAccountMailboxes(built);
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedView) {
|
||||||
|
exitUnifiedView();
|
||||||
|
}
|
||||||
|
|
||||||
selectMailbox(mailboxId);
|
selectMailbox(mailboxId);
|
||||||
selectEmail(null); // Clear selected email when switching mailboxes
|
selectEmail(null); // Clear selected email when switching mailboxes
|
||||||
|
|
||||||
@@ -942,6 +1060,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (!isFilterEmpty(searchFilters)) {
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
@@ -960,6 +1079,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleAdvancedSearch = async () => {
|
const handleAdvancedSearch = async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -969,9 +1089,9 @@ export default function Home() {
|
|||||||
clearTimeout(advancedSearchDebounceRef.current);
|
clearTimeout(advancedSearchDebounceRef.current);
|
||||||
}
|
}
|
||||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||||
if (client) advancedSearch(client);
|
if (client && !isUnifiedView) advancedSearch(client);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [client, advancedSearch]);
|
}, [client, advancedSearch, isUnifiedView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1100,13 +1220,29 @@ export default function Home() {
|
|||||||
|
|
||||||
// Fetch the full content
|
// Fetch the full content
|
||||||
try {
|
try {
|
||||||
// Find selected mailbox to determine accountId (for shared folders)
|
// In unified view each email carries its own accountId. Use that
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
// account's client so we fetch from the server that actually owns it.
|
||||||
// Only pass accountId for shared mailboxes
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||||
|
const perAccountClient = emailAccountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
|
|
||||||
const fullEmail = await client.getEmail(email.id, accountId);
|
// For shared folders on the primary client, we still need to pass the
|
||||||
|
// shared account's id. In unified view we use the per-account client
|
||||||
|
// directly, so no explicit accountId is needed.
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const accountId = perAccountClient
|
||||||
|
? undefined
|
||||||
|
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
|
||||||
|
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||||
if (fullEmail) {
|
if (fullEmail) {
|
||||||
|
if (emailAccountId) {
|
||||||
|
fullEmail.accountId = emailAccountId;
|
||||||
|
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||||
|
}
|
||||||
selectEmail(fullEmail);
|
selectEmail(fullEmail);
|
||||||
// Mark-as-read logic is now handled by useEffect
|
// Mark-as-read logic is now handled by useEffect
|
||||||
}
|
}
|
||||||
@@ -1119,7 +1255,7 @@ 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
|
// Delegate to the browser history stack so this button is equivalent to
|
||||||
// the OS back button / mouse back button — popstate then restores the
|
// the OS back button / mouse back button - popstate then restores the
|
||||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||||
// from a state that pushed history, so back() always lands on an app entry.
|
// from a state that pushed history, so back() always lands on an app entry.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
@@ -1370,6 +1506,8 @@ export default function Home() {
|
|||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
|
disabled={isUnifiedView}
|
||||||
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1385,13 +1523,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
|
disabled={isUnifiedView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title={t("advanced_search.toggle_filters")}
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -1575,8 +1715,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={async (email) => {
|
onDelete={async (email) => {
|
||||||
selectEmail(email);
|
await handleDelete(email);
|
||||||
await handleDelete();
|
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
await handleArchive(email);
|
await handleArchive(email);
|
||||||
@@ -1590,12 +1729,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMarkAsSpam={async (email) => {
|
onMarkAsSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleMarkAsSpam(email);
|
||||||
await handleMarkAsSpam();
|
|
||||||
}}
|
}}
|
||||||
onUndoSpam={async (email) => {
|
onUndoSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleUndoSpam(email);
|
||||||
await handleUndoSpam();
|
|
||||||
}}
|
}}
|
||||||
onEditDraft={(email) => {
|
onEditDraft={(email) => {
|
||||||
handleEditDraft(email);
|
handleEditDraft(email);
|
||||||
@@ -1656,8 +1793,9 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmailComposer
|
<EmailComposer
|
||||||
|
key={composerSessionId}
|
||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('slot', slot);
|
formData.append('slot', slot);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
|
|||||||
async function handleDeleteUpload(slot: string) {
|
async function handleDeleteUpload(slot: string) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slot }),
|
body: JSON.stringify({ slot }),
|
||||||
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/change-password', {
|
const res = await apiFetch('/api/admin/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -85,7 +86,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
const jmapHeaders = getJmapHeaders();
|
const jmapHeaders = getJmapHeaders();
|
||||||
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const stalwartAdmin = data.stalwartAdmin === true;
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
@@ -104,7 +105,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
// If Stalwart admin but not yet authenticated, auto-login
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
if (stalwartAdmin) {
|
if (stalwartAdmin) {
|
||||||
const loginRes = await fetch('/api/admin/auth', {
|
const loginRes = await apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
@@ -122,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/auth', {
|
const res = await apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (actionFilter) params.set('action', actionFilter);
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/audit?${params}`);
|
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setEntries(data.entries || []);
|
setEntries(data.entries || []);
|
||||||
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDetail(detail: Record<string, unknown>): string {
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||||
if (detail.reason) return String(detail.reason);
|
if (detail.reason) return String(detail.reason);
|
||||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
if (detail.changes && Array.isArray(detail.changes)) {
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
|
|||||||
params.set('perPage', String(perPage));
|
params.set('perPage', String(perPage));
|
||||||
params.set('sort', 'newest');
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/marketplace?${params}`);
|
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
setError(data.error || 'Failed to connect to extension directory');
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/marketplace', {
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface AdminStatus {
|
interface AdminStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||||
fetch('/api/admin/auth'),
|
apiFetch('/api/admin/auth'),
|
||||||
fetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
fetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
fetch('/api/admin/config'),
|
apiFetch('/api/admin/config'),
|
||||||
fetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
fetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
fetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
const jmapRes = await fetch('/api/config');
|
const jmapRes = await apiFetch('/api/config');
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
} catch {
|
} catch {
|
||||||
setJmapHealth('error');
|
setJmapHealth('error');
|
||||||
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
|
|||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '—';
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="max-w-3xl space-y-8">
|
||||||
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Server Info */}
|
{/* Server Info */}
|
||||||
<SettingsSection title="Server" description="Application and connection details">
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
<SettingItem label="Application">
|
<SettingItem label="Application">
|
||||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Connection">
|
<SettingItem label="JMAP Connection">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
@@ -68,8 +69,8 @@ export default function PluginConfigPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [pluginsRes, configRes] = await Promise.all([
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
fetch('/api/admin/plugins'),
|
apiFetch('/api/admin/plugins'),
|
||||||
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (pluginsRes.ok) {
|
if (pluginsRes.ok) {
|
||||||
@@ -117,7 +118,7 @@ export default function PluginConfigPage() {
|
|||||||
|
|
||||||
// Delete if clearing a non-required field
|
// Delete if clearing a non-required field
|
||||||
if (!newVal && !field.required) {
|
if (!newVal && !field.required) {
|
||||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -130,7 +131,7 @@ export default function PluginConfigPage() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key, value }),
|
body: JSON.stringify({ key, value }),
|
||||||
@@ -230,7 +231,7 @@ export default function PluginConfigPage() {
|
|||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
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"
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">— Select —</option>
|
<option value="">- Select -</option>
|
||||||
{field.options.map(opt => (
|
{field.options.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface PluginEntry {
|
interface PluginEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -73,7 +74,7 @@ export default function AdminPluginsPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -95,7 +96,7 @@ export default function AdminPluginsPage() {
|
|||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins');
|
const res = await apiFetch('/api/admin/plugins');
|
||||||
if (res.ok) setPlugins(await res.json());
|
if (res.ok) setPlugins(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -113,7 +114,7 @@ export default function AdminPluginsPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -136,7 +137,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function togglePlugin(id: string, enabled: boolean) {
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -156,7 +157,7 @@ export default function AdminPluginsPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -190,7 +191,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of disabled) {
|
for (const p of disabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
@@ -216,7 +217,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of enabled) {
|
for (const p of enabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
@@ -236,7 +237,7 @@ export default function AdminPluginsPage() {
|
|||||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Save, Loader2, Lock } from 'lucide-react';
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
// Feature gates managed on their own admin pages (excluded from this list)
|
// Feature gates managed on their own admin pages (excluded from this list)
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||||
@@ -54,7 +55,7 @@ export default function AdminPolicyPage() {
|
|||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -106,7 +107,7 @@ export default function AdminPolicyPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setConfig(await res.json());
|
setConfig(await res.json());
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy({
|
setPolicy({
|
||||||
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
|
|||||||
async function fetchThemes() {
|
async function fetchThemes() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes');
|
const res = await apiFetch('/api/admin/themes');
|
||||||
if (res.ok) setThemes(await res.json());
|
if (res.ok) setThemes(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function toggleTheme(id: string, enabled: boolean) {
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of disabled) {
|
for (const t of disabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of enabled) {
|
for (const t of enabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
|
|||||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/auth — Login
|
* POST /api/admin/auth - Login
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/auth — Check session status
|
* GET /api/admin/auth - Check session status
|
||||||
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/auth — Logout
|
* DELETE /api/admin/auth - Logout
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||||
*
|
*
|
||||||
* This endpoint is public (no admin auth) so browsers can load images.
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
* Only files in the branding directory are served; directory traversal is prevented.
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/branding — Upload a branding image file
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
*
|
*
|
||||||
* Expects JSON body: { slot: string }
|
* Expects JSON body: { slot: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/change-password — Change admin password
|
* POST /api/admin/change-password - Change admin password
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
|||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
* Proxies to the extension directory API
|
* Proxies to the extension directory API
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/marketplace — Install an extension from the directory
|
* POST /api/admin/marketplace - Install an extension from the directory
|
||||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
*
|
*
|
||||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/a
|
|||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
||||||
*
|
*
|
||||||
* Returns the full config object for admin-configured plugin settings.
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
* This endpoint is accessible from the client-side plugin API.
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
@@ -35,7 +35,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/plugins/[id]/config — Set a config key
|
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string, value: unknown }
|
* Body: { key: string, value: unknown }
|
||||||
* Requires admin authentication (checked via admin session).
|
* Requires admin authentication (checked via admin session).
|
||||||
@@ -83,7 +83,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string }
|
* Body: { key: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins — List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +41,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -218,7 +218,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins — Remove a plugin
|
* DELETE /api/admin/plugins - Remove a plugin
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +22,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/stalwart-check
|
* GET /api/admin/stalwart-check
|
||||||
* Check if the currently logged-in user is a Stalwart admin.
|
* Check if the currently logged-in user is a Stalwart admin.
|
||||||
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
|||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes — List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +32,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/themes — Remove a theme
|
* DELETE /api/admin/themes - Remove a theme
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT — retrieve full credentials (including password) for session restoration.
|
* PUT - retrieve full credentials (including password) for session restoration.
|
||||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.SESSION_SECRET) {
|
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -65,7 +66,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network error — endpoint not reachable
|
// Network error - endpoint not reachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ async function attemptAllStrategies(
|
|||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -33,8 +34,8 @@ export async function GET() {
|
|||||||
oauthOnly,
|
oauthOnly,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||||
stalwartFeaturesEnabled,
|
stalwartFeaturesEnabled,
|
||||||
devMode: configManager.get<boolean>('devMode', false),
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
*
|
*
|
||||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
* No admin auth required — this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
|
// Cache resized images in memory to avoid reprocessing on every request
|
||||||
|
const cache = new Map<number, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
// Absolute URL (http/https)
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path relative to public/ directory
|
||||||
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
|
return readFile(publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ size: string }> }
|
||||||
|
) {
|
||||||
|
const { size: sizeParam } = await params;
|
||||||
|
const size = parseInt(sizeParam, 10);
|
||||||
|
|
||||||
|
if (!VALID_SIZES.has(size)) {
|
||||||
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||||
|
if (!iconUrl) {
|
||||||
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(size)) {
|
||||||
|
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(size, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA icon:', err);
|
||||||
|
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
|||||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
function classifyError(error: unknown): { message: string; status: number } {
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
@@ -48,7 +49,7 @@ function classifyError(error: unknown): { message: string; status: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||||
@@ -134,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce admin policy — strip locked settings so users can't override them
|
// Enforce admin policy - strip locked settings so users can't override them
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
const filteredSettings = { ...settings };
|
const filteredSettings = { ...settings };
|
||||||
|
|||||||
@@ -208,6 +208,11 @@ body {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.email-content-text a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
|||||||
+19
-2
@@ -1,3 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- Generator: Gravit.io -->
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
|
<defs>
|
||||||
|
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||||
|
<style>
|
||||||
|
.icon-bg { fill: #ffffff; }
|
||||||
|
.icon-mark { fill: rgb(219,45,84); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.icon-bg { fill: #18181b; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||||
|
<rect width="1000" height="1000" class="icon-bg"/>
|
||||||
|
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -22,7 +22,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
description: "Minimalist webmail client using JMAP protocol",
|
||||||
manifest: "/manifest.json",
|
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "black-translucent",
|
statusBarStyle: "black-translucent",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
const appName =
|
||||||
|
process.env.APP_NAME ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
|
"Bulwark Webmail";
|
||||||
|
|
||||||
|
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||||
|
const description =
|
||||||
|
process.env.APP_DESCRIPTION ||
|
||||||
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
|
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||||
|
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||||
|
|
||||||
|
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||||
|
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||||
|
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||||
|
|
||||||
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
|
? [
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description,
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
theme_color: themeColor,
|
||||||
|
background_color: backgroundColor,
|
||||||
|
icons,
|
||||||
|
categories: ["productivity"],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||||
|
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -380,8 +380,10 @@ export function EventModal({
|
|||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
attendees
|
attendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
|
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||||
} else if (attendees.length === 0 && event?.participants) {
|
} else if (attendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
data.replyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
setIsParsing(true);
|
setIsParsing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/fetch-ical", {
|
const response = await apiFetch("/api/fetch-ical", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: trimmed }),
|
body: JSON.stringify({ url: trimmed }),
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
try {
|
try {
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
let derBytes: ArrayBuffer | string | null = null;
|
||||||
if (key.uri.startsWith('data:')) {
|
if (key.uri.startsWith('data:')) {
|
||||||
// data URI — extract base64 content
|
// data URI - extract base64 content
|
||||||
const commaIdx = key.uri.indexOf(',');
|
const commaIdx = key.uri.indexOf(',');
|
||||||
if (commaIdx === -1) continue;
|
if (commaIdx === -1) continue;
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
const b64 = key.uri.substring(commaIdx + 1);
|
||||||
@@ -178,7 +178,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||||
{hasNickname && (
|
{hasNickname && (
|
||||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +278,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -371,7 +371,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{contact.speakToAs.pronouns && (() => {
|
{contact.speakToAs.pronouns && (() => {
|
||||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
return firstPronoun ? (
|
return firstPronoun ? (
|
||||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name & Identity — full width */}
|
{/* Name & Identity - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
@@ -575,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setEmails(next);
|
setEmails(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -639,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setPhones(next);
|
setPhones(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -677,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Addresses — full width */}
|
{/* Addresses - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -698,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
value={addr.context}
|
value={addr.context}
|
||||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -833,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="masculine">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="feminine">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="other">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
@@ -866,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Notes — full width */}
|
{/* Notes - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -911,7 +911,6 @@ function CategoryComboBox({
|
|||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Parse current keywords from comma-separated string
|
// Parse current keywords from comma-separated string
|
||||||
@@ -946,17 +945,6 @@ function CategoryComboBox({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
}, [currentKeywords, onChange]);
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
@@ -970,7 +958,7 @@ function CategoryComboBox({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative">
|
<div className="relative">
|
||||||
{/* Keyword badges */}
|
{/* Keyword badges */}
|
||||||
{currentKeywords.length > 0 && (
|
{currentKeywords.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
@@ -998,6 +986,7 @@ function CategoryComboBox({
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onBlur={() => setIsOpen(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||||
/>
|
/>
|
||||||
@@ -1005,7 +994,7 @@ function CategoryComboBox({
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||||
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
|
||||||
{suggestions.map(kw => (
|
{suggestions.map(kw => (
|
||||||
<button
|
<button
|
||||||
key={kw}
|
key={kw}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||||
{cEmail && cName && (
|
{cEmail && cName && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{name || email || "—"}
|
{name || email || "-"}
|
||||||
</div>
|
</div>
|
||||||
{density !== 'extra-compact' && email && name && (
|
{density !== 'extra-compact' && email && name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| null;
|
|| null;
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||||
// Called after updating the local calendar event. Best-effort — if it
|
// Called after updating the local calendar event. Best-effort - if it
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
// fails we still report the RSVP as sent since the calendar was updated.
|
||||||
const sendImipReply = async () => {
|
const sendImipReply = async () => {
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
|||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
@@ -84,6 +85,7 @@ interface EmailComposerProps {
|
|||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
receivedAt?: string;
|
receivedAt?: string;
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +105,8 @@ export function EmailComposer({
|
|||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
@@ -212,6 +216,8 @@ export function EmailComposer({
|
|||||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||||
|
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||||
|
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||||
|
|
||||||
const saveTemplateModalRef = useFocusTrap({
|
const saveTemplateModalRef = useFocusTrap({
|
||||||
isActive: showSaveAsTemplate,
|
isActive: showSaveAsTemplate,
|
||||||
@@ -225,6 +231,12 @@ export function EmailComposer({
|
|||||||
restoreFocus: true,
|
restoreFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const attachmentWarningRef = useFocusTrap({
|
||||||
|
isActive: showAttachmentWarning,
|
||||||
|
onEscape: () => setShowAttachmentWarning(false),
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
@@ -244,12 +256,28 @@ export function EmailComposer({
|
|||||||
|
|
||||||
if (matchedIdentityId) {
|
if (matchedIdentityId) {
|
||||||
setSelectedIdentityId(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,
|
autoSelectReplyIdentity,
|
||||||
identities,
|
identities,
|
||||||
initialData?.selectedIdentityId,
|
initialData?.selectedIdentityId,
|
||||||
mode,
|
mode,
|
||||||
|
replyTo?.accountId,
|
||||||
replyTo?.bcc,
|
replyTo?.bcc,
|
||||||
replyTo?.cc,
|
replyTo?.cc,
|
||||||
replyTo?.to,
|
replyTo?.to,
|
||||||
@@ -723,7 +751,7 @@ export function EmailComposer({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async (skipAttachmentCheck = false) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -742,6 +770,21 @@ export function EmailComposer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment reminder check
|
||||||
|
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||||
|
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
|
||||||
|
if (!hasAttachments) {
|
||||||
|
const bodyText = htmlToPlainText(body);
|
||||||
|
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||||
|
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||||
|
if (matched) {
|
||||||
|
setAttachmentWarningKeyword(matched);
|
||||||
|
setShowAttachmentWarning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let finalDraftId = draftId;
|
let finalDraftId = draftId;
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -1002,7 +1045,7 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
{/* Mobile: send button in header */}
|
{/* Mobile: send button in header */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title={getSendTooltip()}
|
title={getSendTooltip()}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1367,7 +1410,7 @@ export function EmailComposer({
|
|||||||
{t('discard')}
|
{t('discard')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title={getSendTooltip()}
|
title={getSendTooltip()}
|
||||||
className="hidden md:inline-flex"
|
className="hidden md:inline-flex"
|
||||||
@@ -1467,6 +1510,36 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAttachmentWarning && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||||
|
onClick={() => setShowAttachmentWarning(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={attachmentWarningRef}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||||
|
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||||
|
{t('forgot_attachment.back')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
||||||
|
{t('forgot_attachment.send_anyway')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCloseDialog && (
|
{showCloseDialog && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||||
|
|||||||
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
// Get all active label/color tag IDs from email keywords
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.startsWith("$label:")
|
tags.push(
|
||||||
? key.slice("$label:".length)
|
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||||
: key.slice("$color:".length);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmailContextMenu({
|
export function EmailContextMenu({
|
||||||
@@ -137,7 +138,7 @@ export function EmailContextMenu({
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColor = getCurrentColor(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
|
||||||
@@ -306,24 +307,27 @@ export function EmailContextMenu({
|
|||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
role="menuitem"
|
<button
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
key={option.value}
|
||||||
className={cn(
|
role="menuitem"
|
||||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="flex-1">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && (
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
<span className="flex-1">{option.name}</span>
|
||||||
)}
|
{isActive && (
|
||||||
</button>
|
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||||
))}
|
)}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
|||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { EmailHoverActions } from "./email-hover-actions";
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -51,9 +51,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagIds = getEmailColorTags(email.keywords);
|
||||||
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
|
// Use first tag for background coloring
|
||||||
|
const keywordDef = keywordDefs[0] ?? null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality
|
||||||
@@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
{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(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
@@ -249,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{keywordDef && (
|
{keywordDefs.map((kd) => (
|
||||||
<span className={cn(
|
<span key={kd.id} className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"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")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||||
{keywordDef.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs tabular-nums",
|
"text-xs tabular-nums",
|
||||||
isUnread
|
isUnread
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function EmailList({
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await batchDelete(client);
|
await batchDelete(client, isInTrash);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setIsProcessing(false), 500);
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
|||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||||
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
|||||||
return 'Attachment';
|
return 'Attachment';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.startsWith("$label:")
|
tags.push(
|
||||||
? key.slice("$label:".length)
|
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||||
: key.slice("$color:".length);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format recipients with contextual display
|
// Helper function to format recipients with contextual display
|
||||||
@@ -739,7 +740,7 @@ function ContactSidebarPanel({
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -887,6 +888,9 @@ export function EmailViewer({
|
|||||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
@@ -933,7 +937,8 @@ export function EmailViewer({
|
|||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||||
const currentColor = getCurrentColor(email?.keywords);
|
const currentColors = getCurrentColors(email?.keywords);
|
||||||
|
const currentColor = currentColors[0] ?? null;
|
||||||
|
|
||||||
// S/MIME state
|
// S/MIME state
|
||||||
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
||||||
@@ -1165,7 +1170,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already read — record that so manual unread toggle won't re-trigger auto-mark
|
// Already read - record that so manual unread toggle won't re-trigger auto-mark
|
||||||
if (email.keywords?.$seen) {
|
if (email.keywords?.$seen) {
|
||||||
autoMarkedEmailRef.current = email.id;
|
autoMarkedEmailRef.current = email.id;
|
||||||
return;
|
return;
|
||||||
@@ -1639,7 +1644,7 @@ export function EmailViewer({
|
|||||||
// Check if inner content is also signed
|
// Check if inner content is also signed
|
||||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||||
if (nestedSignedData) {
|
if (nestedSignedData) {
|
||||||
// Nested sign-then-encrypt — verify inner signature
|
// Nested sign-then-encrypt - verify inner signature
|
||||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||||
source: nestedSignedData.source,
|
source: nestedSignedData.source,
|
||||||
@@ -1848,7 +1853,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
@@ -1893,7 +1898,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1907,7 +1912,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1970,7 +1975,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -2079,11 +2084,15 @@ export function EmailViewer({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
|
||||||
const jmapAttachments = (email?.attachments ?? [])
|
const jmapAttachments = (email?.attachments ?? [])
|
||||||
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
||||||
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
||||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||||
|
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||||
|
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||||
|
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||||
.map((attachment, index) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
name: attachment.name || null,
|
name: attachment.name || null,
|
||||||
@@ -2114,7 +2123,7 @@ export function EmailViewer({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
|
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
@@ -2309,9 +2318,11 @@ export function EmailViewer({
|
|||||||
// Use shared sanitization config as base (more secure)
|
// Use shared sanitization config as base (more secure)
|
||||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||||
|
|
||||||
// Check if sender is trusted
|
// Check if sender is trusted (localStorage list or address book)
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
const senderIsTrusted = senderEmail
|
||||||
|
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||||
|
: false;
|
||||||
|
|
||||||
// Block external content based on policy:
|
// Block external content based on policy:
|
||||||
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
||||||
@@ -2418,7 +2429,7 @@ export function EmailViewer({
|
|||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
|
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -2950,7 +2961,7 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="toolbar-actions" />
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||||
@@ -3047,7 +3058,7 @@ export function EmailViewer({
|
|||||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5" />
|
||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
@@ -3055,43 +3066,51 @@ export function EmailViewer({
|
|||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||||
currentColor && "bg-muted/50"
|
currentColors.length > 0 && "bg-muted/50"
|
||||||
)}
|
)}
|
||||||
title={t('set_color')}
|
title={t('set_color')}
|
||||||
>
|
>
|
||||||
{(() => {
|
{currentColors.length > 0 ? (
|
||||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
<>
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
<span className="flex items-center gap-0.5">
|
||||||
return dotClass ? (
|
{currentColors.slice(0, 3).map((tagId) => {
|
||||||
<>
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
|
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||||
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
|
})}
|
||||||
</>
|
</span>
|
||||||
) : (
|
{showToolbarLabels && currentColors.length === 1 && (
|
||||||
<>
|
<span className="text-xs font-medium text-foreground">
|
||||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
</span>
|
||||||
</>
|
)}
|
||||||
);
|
</>
|
||||||
})()}
|
) : (
|
||||||
|
<>
|
||||||
|
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||||
|
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{tagMenuOpen && (
|
{tagMenuOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="truncate">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
@@ -3108,7 +3127,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3130,7 +3149,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3143,7 +3162,7 @@ export function EmailViewer({
|
|||||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Print — hidden on mobile, overflows to More menu */}
|
{/* Print - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3157,7 +3176,7 @@ export function EmailViewer({
|
|||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* View source — hidden on mobile, overflows to More menu */}
|
{/* View source - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3185,7 +3204,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More menu — click-based */}
|
{/* More menu - click-based */}
|
||||||
<div ref={moreMenuRef} className="relative">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3231,7 +3250,7 @@ export function EmailViewer({
|
|||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('archive')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
{/* Overflow: move to folder — submenu */}
|
{/* Overflow: move to folder - submenu */}
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('move')}
|
onMouseEnter={() => setMoreMenuSub('move')}
|
||||||
@@ -3283,7 +3302,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag — submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
@@ -3299,21 +3318,24 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{moreMenuSub === 'tag' && (
|
{moreMenuSub === 'tag' && (
|
||||||
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="truncate">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
@@ -3489,21 +3511,24 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="truncate">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
@@ -3649,14 +3674,18 @@ export function EmailViewer({
|
|||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Color tag dot */}
|
{/* Color tag dots */}
|
||||||
{currentColor && (() => {
|
{currentColors.length > 0 && (
|
||||||
const kw = emailKeywords.find(k => k.id === currentColor);
|
<span className="flex items-center gap-0.5">
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
{currentColors.map((tagId) => {
|
||||||
return dotClass ? (
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||||
) : null;
|
return (
|
||||||
})()}
|
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isImportant && (
|
{isImportant && (
|
||||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||||
{t('important')}
|
{t('important')}
|
||||||
@@ -4010,7 +4039,7 @@ export function EmailViewer({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||||
Spam Score
|
Spam Score
|
||||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-xs",
|
"text-xs",
|
||||||
@@ -4545,7 +4574,11 @@ export function EmailViewer({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const senderEmail = email.from?.[0]?.email;
|
const senderEmail = email.from?.[0]?.email;
|
||||||
if (senderEmail) {
|
if (senderEmail) {
|
||||||
addTrustedSender(senderEmail);
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
setAllowExternalContent(true);
|
setAllowExternalContent(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Display label override (e.g. "me") */
|
/** Display label override (e.g. "me") */
|
||||||
displayLabel?: string;
|
displayLabel?: string;
|
||||||
/** Called when user clicks "View contact" — receives the contact and email */
|
/** Called when user clicks "View contact" - receives the contact and email */
|
||||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ export function ThreadConversationView({
|
|||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
|
||||||
// Track which emails are expanded (most recent by default)
|
// Track which emails are expanded (most recent by default)
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -164,7 +169,9 @@ export function ThreadConversationView({
|
|||||||
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
||||||
{emails.map((email, index) => {
|
{emails.map((email, index) => {
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
const senderIsTrusted = senderEmail
|
||||||
|
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||||
|
: false;
|
||||||
return (
|
return (
|
||||||
<EmailCard
|
<EmailCard
|
||||||
key={email.id}
|
key={email.id}
|
||||||
@@ -175,7 +182,11 @@ export function ThreadConversationView({
|
|||||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||||
onTrustSender={senderEmail ? () => {
|
onTrustSender={senderEmail ? () => {
|
||||||
addTrustedSender(senderEmail);
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
toggleAllowExternal(email.id);
|
toggleAllowExternal(email.id);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onReply={onReply ? () => onReply(email) : undefined}
|
onReply={onReply ? () => onReply(email) : undefined}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
|
|||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
@@ -63,13 +64,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
|
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||||
const resolvedColorTag = (() => {
|
const resolvedColorTag = (() => {
|
||||||
if (colorTag) return colorTag;
|
if (colorTag) return colorTag;
|
||||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||||
@@ -183,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{isFocusedMailLayout ? (
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
{isUnifiedView && email.accountId && accountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: accountColor }}
|
||||||
|
title={email.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
@@ -212,7 +224,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
|
{resolvedKeywordDefs.map((kd) => (
|
||||||
|
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||||
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
@@ -225,6 +239,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{isUnifiedView && email.accountId && accountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: accountColor }}
|
||||||
|
title={email.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -255,15 +276,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{resolvedKeywordDef && (
|
{resolvedKeywordDefs.map((kd) => (
|
||||||
<span className={cn(
|
<span key={kd.id} className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||||
)}>
|
)}>
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||||
{resolvedKeywordDef.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs tabular-nums",
|
"text-xs tabular-nums",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -342,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||||
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
|
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
@@ -372,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const threadColor = getThreadColorTag(thread.emails);
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||||
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
@@ -545,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{isFocusedMailLayout ? (
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: threadAccountColor }}
|
||||||
|
title={latestEmail.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
@@ -584,7 +614,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
{keywordDef && (
|
||||||
|
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
@@ -597,6 +629,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: threadAccountColor }}
|
||||||
|
title={latestEmail.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
hasUnread
|
hasUnread
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function FileBrowser({
|
|||||||
// Filter and sort resources
|
// Filter and sort resources
|
||||||
const displayResources = useMemo(() => {
|
const displayResources = useMemo(() => {
|
||||||
let filtered = resources;
|
let filtered = resources;
|
||||||
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
|
// In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
|
||||||
if (folderLayout === "sidebar") {
|
if (folderLayout === "sidebar") {
|
||||||
filtered = filtered.filter(r => !r.isDirectory);
|
filtered = filtered.filter(r => !r.isDirectory);
|
||||||
}
|
}
|
||||||
@@ -1466,7 +1466,7 @@ export function FileBrowser({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||||
{resource.isDirectory ? "—" : formatFileSize(resource.contentLength)}
|
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
||||||
{formatDate(resource.lastModified)}
|
{formatDate(resource.lastModified)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't cache empty root results — empty root likely means client wasn't ready yet
|
// Don't cache empty root results - empty root likely means client wasn't ready yet
|
||||||
if (folders.length > 0 || parentId !== null) {
|
if (folders.length > 0 || parentId !== null) {
|
||||||
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from "@/lib/jmap/sieve-types";
|
} from "@/lib/jmap/sieve-types";
|
||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -58,6 +59,7 @@ export function FilterRuleModal({
|
|||||||
}: FilterRuleModalProps) {
|
}: FilterRuleModalProps) {
|
||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const isEdit = !!rule;
|
const isEdit = !!rule;
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || "");
|
const [name, setName] = useState(rule?.name || "");
|
||||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||||
@@ -375,12 +377,17 @@ export function FilterRuleModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{action.type === "add_label" && (
|
{action.type === "add_label" && (
|
||||||
<Input
|
<select
|
||||||
value={action.value || ""}
|
value={action.value || ""}
|
||||||
onChange={(e) => updateAction(index, { value: e.target.value })}
|
onChange={(e) => updateAction(index, { value: e.target.value })}
|
||||||
placeholder={t("label_placeholder")}
|
className={`${selectClass} flex-1 min-w-[140px]`}
|
||||||
className="flex-1 min-w-[140px]"
|
aria-label={t("label_placeholder")}
|
||||||
/>
|
>
|
||||||
|
<option value="">{t("label_placeholder")}</option>
|
||||||
|
{emailKeywords.map((kw) => (
|
||||||
|
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getInitials } from "@/lib/account-utils";
|
|||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -222,13 +223,13 @@ export function NavigationRail({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const headers = getActiveAccountSlotHeaders();
|
const headers = getActiveAccountSlotHeaders();
|
||||||
if (!headers['X-JMAP-Cookie-Slot']) return;
|
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||||
fetch('/api/admin/stalwart-check', { headers })
|
apiFetch('/api/admin/stalwart-check', { headers })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!cancelled && data.isStalwartAdmin) {
|
if (!cancelled && data.isStalwartAdmin) {
|
||||||
setIsStalwartAdmin(true);
|
setIsStalwartAdmin(true);
|
||||||
// Pre-create admin session so /admin works even after full page navigation
|
// Pre-create admin session so /admin works even after full page navigation
|
||||||
fetch('/api/admin/auth', {
|
apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...headers },
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function SidebarAppForm({
|
|||||||
{t('icon_label')} <span className="text-destructive">*</span>
|
{t('icon_label')} <span className="text-destructive">*</span>
|
||||||
{SelectedIcon && (
|
{SelectedIcon && (
|
||||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+570
-359
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import nlMessages from '@/locales/nl/common.json';
|
|||||||
import plMessages from '@/locales/pl/common.json';
|
import plMessages from '@/locales/pl/common.json';
|
||||||
import ptMessages from '@/locales/pt/common.json';
|
import ptMessages from '@/locales/pt/common.json';
|
||||||
import ruMessages from '@/locales/ru/common.json';
|
import ruMessages from '@/locales/ru/common.json';
|
||||||
|
import ukMessages from '@/locales/uk/common.json';
|
||||||
import zhMessages from '@/locales/zh/common.json';
|
import zhMessages from '@/locales/zh/common.json';
|
||||||
|
|
||||||
// Pre-loaded translations (loaded at build time, not runtime)
|
// Pre-loaded translations (loaded at build time, not runtime)
|
||||||
@@ -31,6 +32,7 @@ const ALL_MESSAGES = {
|
|||||||
pl: plMessages,
|
pl: plMessages,
|
||||||
pt: ptMessages,
|
pt: ptMessages,
|
||||||
ru: ruMessages,
|
ru: ruMessages,
|
||||||
|
uk: ukMessages,
|
||||||
zh: zhMessages,
|
zh: zhMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { X, Download } from "lucide-react";
|
import { X, Download } from "lucide-react";
|
||||||
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISMISSED_KEY = "pwa-install-dismissed";
|
||||||
|
|
||||||
export function PWAInstallPrompt() {
|
export function PWAInstallPrompt() {
|
||||||
const [deferredPrompt, setDeferredPrompt] =
|
const [deferredPrompt, setDeferredPrompt] =
|
||||||
useState<BeforeInstallPromptEvent | null>(null);
|
useState<BeforeInstallPromptEvent | null>(null);
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||||
|
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
@@ -43,18 +49,40 @@ export function PWAInstallPrompt() {
|
|||||||
setShowPrompt(false);
|
setShowPrompt(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDismissForever = () => {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, "1");
|
||||||
|
setShowPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (!showPrompt || !deferredPrompt) {
|
if (!showPrompt || !deferredPrompt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logoSrc = appLogoLightUrl || faviconUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
{logoSrc ? (
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt={appName}
|
||||||
|
className="w-8 h-8 shrink-0 object-contain dark:hidden"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
{logoSrc && (
|
||||||
|
<img
|
||||||
|
src={appLogoDarkUrl || faviconUrl}
|
||||||
|
alt={appName}
|
||||||
|
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||||
Install Bulwark
|
Install {appName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
Install our app for quick access and offline support.
|
Install our app for quick access and offline support.
|
||||||
@@ -69,18 +97,26 @@ export function PWAInstallPrompt() {
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDismiss}
|
onClick={handleDismissForever}
|
||||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
||||||
>
|
>
|
||||||
Not now
|
Don't remind me again
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleInstall}
|
|
||||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PlayCircle } from 'lucide-react';
|
import { PlayCircle } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
|
|
||||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||||
@@ -21,7 +22,7 @@ const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: bo
|
|||||||
function DensityPreview({ density }: { density: Density }) {
|
function DensityPreview({ density }: { density: Density }) {
|
||||||
const cfg = DENSITY_PREVIEW[density];
|
const cfg = DENSITY_PREVIEW[density];
|
||||||
const rows = [
|
const rows = [
|
||||||
{ unread: true, sender: 'Alice Johnson', subject: 'Project update — Q1 roadmap', preview: 'Here are the latest numbers from…' },
|
{ unread: true, sender: 'Alice Johnson', subject: 'Project update - Q1 roadmap', preview: 'Here are the latest numbers from…' },
|
||||||
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
|
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
|
||||||
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
|
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
|
||||||
];
|
];
|
||||||
@@ -67,9 +68,10 @@ export function AppearanceSettings() {
|
|||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
const tTour = useTranslations('tour');
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
|
||||||
const { startTour, resetTourCompletion } = useTour();
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
const accounts = useAccountStore(s => s.accounts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Colorful Sidebar Icons */}
|
||||||
|
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={colorfulSidebarIcons}
|
||||||
|
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Unified Mailbox */}
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<SettingItem
|
||||||
|
label={t('unified_mailbox.label')}
|
||||||
|
description={t('unified_mailbox.description')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={enableUnifiedMailbox}
|
||||||
|
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Animations */}
|
{/* Animations */}
|
||||||
{!isSettingHidden('animationsEnabled') && (
|
{!isSettingHidden('animationsEnabled') && (
|
||||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { cn, formatDateTime } from '@/lib/utils';
|
|||||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const CALENDAR_COLORS = [
|
const CALENDAR_COLORS = [
|
||||||
"#3b82f6", // blue
|
"#3b82f6", // blue
|
||||||
@@ -202,7 +203,7 @@ export function CalendarManagementSettings() {
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
fetch('/api/caldav/discover', {
|
apiFetch('/api/caldav/discover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useContactStore } from '@/stores/contact-store';
|
||||||
|
|
||||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||||
@@ -110,6 +111,8 @@ export function EmailSettings() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [newKeyword, setNewKeyword] = useState('');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
markAsReadDelay,
|
markAsReadDelay,
|
||||||
deleteAction,
|
deleteAction,
|
||||||
@@ -129,12 +132,16 @@ export function EmailSettings() {
|
|||||||
hoverActionsMode,
|
hoverActionsMode,
|
||||||
hoverActionsCorner,
|
hoverActionsCorner,
|
||||||
trustedSenders,
|
trustedSenders,
|
||||||
|
trustedSendersAddressBook,
|
||||||
|
attachmentReminderEnabled,
|
||||||
|
attachmentReminderKeywords,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
const { trustedSenderEmails } = useContactStore();
|
||||||
|
|
||||||
// Get count label for trusted senders button
|
// Get count label for trusted senders button
|
||||||
const getTrustedSendersCount = () => {
|
const getTrustedSendersCount = () => {
|
||||||
const count = trustedSenders.length;
|
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||||
if (count === 0) return t('trusted_senders.count_zero');
|
if (count === 0) return t('trusted_senders.count_zero');
|
||||||
if (count === 1) return t('trusted_senders.count_one');
|
if (count === 1) return t('trusted_senders.count_one');
|
||||||
return t('trusted_senders.count_other', { count });
|
return t('trusted_senders.count_other', { count });
|
||||||
@@ -337,6 +344,63 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Attachment Reminder */}
|
||||||
|
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={attachmentReminderEnabled}
|
||||||
|
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
{attachmentReminderEnabled && (
|
||||||
|
<div className="py-3 border-b border-border space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{attachmentReminderKeywords.map((kw) => (
|
||||||
|
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||||
|
{kw}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('attachment_reminder.remove')}
|
||||||
|
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = newKeyword.trim().toLowerCase();
|
||||||
|
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||||
|
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||||
|
}
|
||||||
|
setNewKeyword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newKeyword}
|
||||||
|
onChange={(e) => setNewKeyword(e.target.value)}
|
||||||
|
placeholder={t('attachment_reminder.add_placeholder')}
|
||||||
|
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newKeyword.trim()}
|
||||||
|
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('attachment_reminder.add')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Hover Actions */}
|
{/* Quick Hover Actions */}
|
||||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
@@ -511,6 +575,14 @@ export function EmailSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Trusted Senders - address book storage */}
|
||||||
|
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={trustedSendersAddressBook}
|
||||||
|
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Trusted Senders Modal */}
|
{/* Trusted Senders Modal */}
|
||||||
<TrustedSendersModal
|
<TrustedSendersModal
|
||||||
isOpen={showTrustedModal}
|
isOpen={showTrustedModal}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return "—";
|
if (bytes === 0) return "-";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
PalmtreeIcon,
|
PalmtreeIcon,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function isReadonlyRule(r: FilterRule): boolean {
|
||||||
|
return r.origin === "external" || r.origin === "opaque";
|
||||||
|
}
|
||||||
|
|
||||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
|
|
||||||
@@ -429,90 +434,137 @@ export function FilterSettings() {
|
|||||||
|
|
||||||
{!isOpaque && rules.length > 0 && (
|
{!isOpaque && rules.length > 0 && (
|
||||||
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
||||||
{rules.map((rule, index) => (
|
{rules.map((rule, index) => {
|
||||||
<div
|
const readonly = isReadonlyRule(rule);
|
||||||
key={rule.id}
|
|
||||||
role="listitem"
|
if (readonly) {
|
||||||
draggable
|
const label = rule.originLabel || t("origin_external");
|
||||||
onDragStart={(e) => handleDragStart(e, index)}
|
const tooltip = t("managed_by_tooltip", { source: label });
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
const hasStructuredSummary =
|
||||||
onDrop={(e) => handleDrop(e, index)}
|
rule.origin === "external" &&
|
||||||
onDragEnd={handleDragEnd}
|
rule.conditions.length > 0 &&
|
||||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
rule.actions.length > 0;
|
||||||
dragOverIndex === index
|
return (
|
||||||
? "border-primary bg-primary/5"
|
<div
|
||||||
: "border-border hover:bg-muted/50"
|
key={rule.id}
|
||||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
role="listitem"
|
||||||
>
|
className="flex items-start gap-3 p-3 rounded-md border border-border"
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{rule.name}
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasStructuredSummary ? (
|
||||||
|
expandedFilterView ? (
|
||||||
|
<VisualRuleSummary rule={rule} />
|
||||||
|
) : (
|
||||||
|
<RuleSummary rule={rule} />
|
||||||
|
)
|
||||||
|
) : rule.rawBlock ? (
|
||||||
|
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
|
||||||
|
{rule.rawBlock.trim()}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
key={rule.id}
|
||||||
aria-label={t("drag_to_reorder")}
|
role="listitem"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||||
|
dragOverIndex === index
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/50"
|
||||||
|
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
<div
|
||||||
</div>
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||||
|
aria-label={t("drag_to_reorder")}
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-0.5">
|
<div className="pt-0.5">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={rule.enabled}
|
checked={rule.enabled}
|
||||||
onChange={() => handleToggle(rule.id)}
|
onChange={() => handleToggle(rule.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingRule(rule);
|
|
||||||
setShowRuleModal(true);
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setEditingRule(rule);
|
setEditingRule(rule);
|
||||||
setShowRuleModal(true);
|
setShowRuleModal(true);
|
||||||
}
|
}}
|
||||||
}}
|
role="button"
|
||||||
>
|
tabIndex={0}
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
onKeyDown={(e) => {
|
||||||
{rule.name}
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
</p>
|
e.preventDefault();
|
||||||
{expandedFilterView ? (
|
setEditingRule(rule);
|
||||||
<VisualRuleSummary rule={rule} />
|
setShowRuleModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{rule.name}
|
||||||
|
</p>
|
||||||
|
{expandedFilterView ? (
|
||||||
|
<VisualRuleSummary rule={rule} />
|
||||||
|
) : (
|
||||||
|
<RuleSummary rule={rule} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteConfirmId === rule.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
>
|
||||||
|
{t("confirm_delete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RuleSummary rule={rule} />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteConfirmId(rule.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
aria-label={t("delete_rule")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{deleteConfirmId === rule.id ? (
|
})}
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(rule.id)}
|
|
||||||
>
|
|
||||||
{t("confirm_delete")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDeleteConfirmId(rule.id)}
|
|
||||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
|
||||||
aria-label={t("delete_rule")}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ export function FolderSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Folder List — primary section */}
|
{/* Folder List - primary section */}
|
||||||
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
|
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{folderTree.length === 0 ? (
|
{folderTree.length === 0 ? (
|
||||||
@@ -516,7 +516,7 @@ export function FolderSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Standard Folder Roles — advanced section */}
|
{/* Standard Folder Roles - advanced section */}
|
||||||
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
||||||
{STANDARD_ROLES.map((role) => {
|
{STANDARD_ROLES.map((role) => {
|
||||||
// Disambiguate duplicate folder names by appending parent path
|
// Disambiguate duplicate folder names by appending parent path
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -41,16 +41,39 @@ function KeywordRow({
|
|||||||
keyword,
|
keyword,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
isDragOver,
|
||||||
|
isDragging,
|
||||||
}: {
|
}: {
|
||||||
keyword: KeywordDefinition;
|
keyword: KeywordDefinition;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDrop: () => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
isDragOver: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const palette = KEYWORD_PALETTE[keyword.color];
|
const palette = KEYWORD_PALETTE[keyword.color];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group">
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
|
||||||
|
isDragging ? "opacity-40" : "opacity-100",
|
||||||
|
isDragOver ? "border-primary" : "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
||||||
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
||||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
||||||
@@ -166,9 +189,39 @@ export function KeywordSettings() {
|
|||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isMigrating, setIsMigrating] = useState(false);
|
const [isMigrating, setIsMigrating] = useState(false);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const existingIds = emailKeywords.map((k) => k.id);
|
const existingIds = emailKeywords.map((k) => k.id);
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDragIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (index !== dragOverIndex) setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (index: number) => {
|
||||||
|
if (dragIndex === null || dragIndex === index) {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...emailKeywords];
|
||||||
|
const [moved] = reordered.splice(dragIndex, 1);
|
||||||
|
reordered.splice(index, 0, moved);
|
||||||
|
reorderKeywords(reordered);
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = (keyword: KeywordDefinition) => {
|
const handleAdd = (keyword: KeywordDefinition) => {
|
||||||
addKeyword(keyword);
|
addKeyword(keyword);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
@@ -220,7 +273,7 @@ export function KeywordSettings() {
|
|||||||
{t("migrating")}
|
{t("migrating")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{emailKeywords.map((keyword) =>
|
{emailKeywords.map((keyword, index) =>
|
||||||
editingId === keyword.id ? (
|
editingId === keyword.id ? (
|
||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
@@ -238,6 +291,12 @@ export function KeywordSettings() {
|
|||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(keyword.id)}
|
onDelete={() => handleDelete(keyword.id)}
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={() => handleDrop(index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
isDragOver={dragOverIndex === index && dragIndex !== index}
|
||||||
|
isDragging={dragIndex === index}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function SmimeCertificateModal({
|
|||||||
{ label: t("cert_email"), value: record.email },
|
{ label: t("cert_email"), value: record.email },
|
||||||
{
|
{
|
||||||
label: t("cert_validity"),
|
label: t("cert_validity"),
|
||||||
value: `${new Date(record.notBefore).toLocaleDateString()} — ${new Date(record.notAfter).toLocaleDateString()}`,
|
value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||||
},
|
},
|
||||||
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
|
import { Shield, Mail, X, AlertTriangle, MailCheck, RotateCcw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const GAME_WIDTH = 400;
|
const GAME_WIDTH = 400;
|
||||||
const GAME_HEIGHT = 520;
|
const GAME_HEIGHT = 520;
|
||||||
const FORTRESS_Y = GAME_HEIGHT - 48;
|
const INBOX_Y = GAME_HEIGHT - 40;
|
||||||
const SPAWN_INTERVAL_START = 850;
|
const SPAWN_INTERVAL_START = 900;
|
||||||
const SPAWN_INTERVAL_MIN = 320;
|
const SPAWN_INTERVAL_MIN = 340;
|
||||||
const GAME_DURATION = 30;
|
const GAME_DURATION = 30;
|
||||||
const ENEMY_SPEED_START = 1.2;
|
const ENEMY_SPEED_START = 1.2;
|
||||||
const ENEMY_SPEED_INCREASE = 0.04;
|
const ENEMY_SPEED_INCREASE = 0.04;
|
||||||
|
const MAX_MISSES = 3;
|
||||||
|
|
||||||
interface Enemy {
|
interface Enemy {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,81 +23,85 @@ interface Enemy {
|
|||||||
type: "spam" | "phishing" | "legit";
|
type: "spam" | "phishing" | "legit";
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameState = "idle" | "playing" | "won" | "lost";
|
type GameState = "idle" | "playing" | "over";
|
||||||
|
|
||||||
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
||||||
const [gameState, setGameState] = useState<GameState>("idle");
|
const [gameState, setGameState] = useState<GameState>("idle");
|
||||||
const [enemies, setEnemies] = useState<Enemy[]>([]);
|
const [enemies, setEnemies] = useState<Enemy[]>([]);
|
||||||
const [score, setScore] = useState(0);
|
const [score, setScore] = useState(0);
|
||||||
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
|
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
|
||||||
const [shieldHealth, setShieldHealth] = useState(3);
|
const [misses, setMisses] = useState(0);
|
||||||
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
|
const [survived, setSurvived] = useState(false);
|
||||||
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
|
|
||||||
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
|
|
||||||
const nextId = useRef(0);
|
const nextId = useRef(0);
|
||||||
const animFrameRef = useRef<number>(0);
|
const animFrameRef = useRef<number>(0);
|
||||||
const lastTimeRef = useRef<number>(0);
|
const lastTimeRef = useRef<number>(0);
|
||||||
const spawnTimerRef = useRef<number>(0);
|
const spawnTimerRef = useRef<number>(0);
|
||||||
const gameStateRef = useRef<GameState>("idle");
|
const gameStateRef = useRef<GameState>("idle");
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
const destroyedRef = useRef(new Set<number>());
|
const clickedRef = useRef(new Set<number>());
|
||||||
|
const enemiesRef = useRef<Enemy[]>([]);
|
||||||
|
const missesRef = useRef(0);
|
||||||
|
const scoreRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gameStateRef.current = gameState;
|
gameStateRef.current = gameState;
|
||||||
}, [gameState]);
|
}, [gameState]);
|
||||||
|
|
||||||
|
const endGame = useCallback((didSurvive: boolean) => {
|
||||||
|
setSurvived(didSurvive);
|
||||||
|
setGameState("over");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const startGame = useCallback(() => {
|
const startGame = useCallback(() => {
|
||||||
setGameState("playing");
|
setGameState("playing");
|
||||||
setEnemies([]);
|
setEnemies([]);
|
||||||
setScore(0);
|
setScore(0);
|
||||||
setTimeLeft(GAME_DURATION);
|
setTimeLeft(GAME_DURATION);
|
||||||
setShieldHealth(3);
|
setMisses(0);
|
||||||
setHitEffects([]);
|
setSurvived(false);
|
||||||
setDestroyEffects([]);
|
|
||||||
setDeliverEffects([]);
|
|
||||||
nextId.current = 0;
|
nextId.current = 0;
|
||||||
spawnTimerRef.current = 0;
|
spawnTimerRef.current = 0;
|
||||||
elapsedRef.current = 0;
|
elapsedRef.current = 0;
|
||||||
destroyedRef.current = new Set();
|
clickedRef.current = new Set();
|
||||||
|
enemiesRef.current = [];
|
||||||
|
missesRef.current = 0;
|
||||||
|
scoreRef.current = 0;
|
||||||
lastTimeRef.current = performance.now();
|
lastTimeRef.current = performance.now();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const spawnEnemy = useCallback(() => {
|
const spawnEnemy = useCallback(() => {
|
||||||
const id = nextId.current++;
|
const id = nextId.current++;
|
||||||
const rand = Math.random();
|
const rand = Math.random();
|
||||||
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
|
const type = rand > 0.75 ? "legit" : rand > 0.45 ? "phishing" : "spam";
|
||||||
const x = 20 + Math.random() * (GAME_WIDTH - 60);
|
const x = 20 + Math.random() * (GAME_WIDTH - 60);
|
||||||
const elapsed = elapsedRef.current;
|
const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE;
|
||||||
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
|
enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }];
|
||||||
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
|
setEnemies(enemiesRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHover = useCallback((enemy: Enemy) => {
|
const handleClick = useCallback(
|
||||||
if (destroyedRef.current.has(enemy.id)) return;
|
(ev: React.MouseEvent, enemy: Enemy) => {
|
||||||
destroyedRef.current.add(enemy.id);
|
ev.stopPropagation();
|
||||||
|
if (clickedRef.current.has(enemy.id)) return;
|
||||||
|
clickedRef.current.add(enemy.id);
|
||||||
|
|
||||||
if (enemy.type === "legit") {
|
enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id);
|
||||||
// Penalty for blocking legit mail
|
setEnemies(enemiesRef.current);
|
||||||
setShieldHealth((prev) => {
|
|
||||||
const nh = prev - 1;
|
|
||||||
if (nh <= 0) setGameState("lost");
|
|
||||||
return Math.max(0, nh);
|
|
||||||
});
|
|
||||||
setScore((prev) => Math.max(0, prev - 15));
|
|
||||||
const effectId = nextId.current++;
|
|
||||||
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
|
|
||||||
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
|
|
||||||
} else {
|
|
||||||
setScore((prev) => prev + 10);
|
|
||||||
const effectId = nextId.current++;
|
|
||||||
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
|
|
||||||
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
|
if (enemy.type === "legit") {
|
||||||
}, []);
|
missesRef.current += 1;
|
||||||
|
setMisses(missesRef.current);
|
||||||
|
scoreRef.current = Math.max(0, scoreRef.current - 15);
|
||||||
|
setScore(scoreRef.current);
|
||||||
|
if (missesRef.current >= MAX_MISSES) endGame(false);
|
||||||
|
} else {
|
||||||
|
scoreRef.current += enemy.type === "phishing" ? 15 : 10;
|
||||||
|
setScore(scoreRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[endGame]
|
||||||
|
);
|
||||||
|
|
||||||
// Game loop
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameState !== "playing") return;
|
if (gameState !== "playing") return;
|
||||||
|
|
||||||
@@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
|||||||
lastTimeRef.current = now;
|
lastTimeRef.current = now;
|
||||||
elapsedRef.current += dt;
|
elapsedRef.current += dt;
|
||||||
|
|
||||||
// Timer
|
|
||||||
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
|
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
|
||||||
setTimeLeft(Math.max(0, newTimeLeft));
|
setTimeLeft(Math.max(0, newTimeLeft));
|
||||||
if (newTimeLeft <= 0) {
|
if (newTimeLeft <= 0) {
|
||||||
setGameState("won");
|
endGame(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn
|
|
||||||
spawnTimerRef.current += dt;
|
spawnTimerRef.current += dt;
|
||||||
const spawnInterval = Math.max(
|
const spawnInterval = Math.max(
|
||||||
SPAWN_INTERVAL_MIN,
|
SPAWN_INTERVAL_MIN,
|
||||||
@@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
|||||||
spawnEnemy();
|
spawnEnemy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move enemies
|
const nextEnemies: Enemy[] = [];
|
||||||
setEnemies((prev) => {
|
let missed = 0;
|
||||||
const next: Enemy[] = [];
|
let scoreDelta = 0;
|
||||||
let spamBreached = false;
|
for (const e of enemiesRef.current) {
|
||||||
for (const e of prev) {
|
const ny = e.y + e.speed * (dt / 16);
|
||||||
const ny = e.y + e.speed * (dt / 16);
|
if (ny >= INBOX_Y) {
|
||||||
if (ny >= FORTRESS_Y) {
|
if (e.type === "legit") scoreDelta += 5;
|
||||||
if (e.type === "legit") {
|
else missed++;
|
||||||
// Legit mail delivered — bonus
|
} else {
|
||||||
setScore((s) => s + 5);
|
nextEnemies.push({ ...e, y: ny });
|
||||||
const effectId = nextId.current++;
|
|
||||||
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
|
|
||||||
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
|
|
||||||
} else {
|
|
||||||
spamBreached = true;
|
|
||||||
const effectId = nextId.current++;
|
|
||||||
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
|
|
||||||
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next.push({ ...e, y: ny });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (spamBreached) {
|
}
|
||||||
setShieldHealth((prev) => {
|
enemiesRef.current = nextEnemies;
|
||||||
const nh = prev - 1;
|
setEnemies(nextEnemies);
|
||||||
if (nh <= 0) setGameState("lost");
|
|
||||||
return Math.max(0, nh);
|
if (scoreDelta > 0) {
|
||||||
});
|
scoreRef.current += scoreDelta;
|
||||||
|
setScore(scoreRef.current);
|
||||||
|
}
|
||||||
|
if (missed > 0) {
|
||||||
|
missesRef.current += missed;
|
||||||
|
setMisses(missesRef.current);
|
||||||
|
if (missesRef.current >= MAX_MISSES) {
|
||||||
|
endGame(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return next;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
animFrameRef.current = requestAnimationFrame(tick);
|
animFrameRef.current = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|
||||||
animFrameRef.current = requestAnimationFrame(tick);
|
animFrameRef.current = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(animFrameRef.current);
|
return () => cancelAnimationFrame(animFrameRef.current);
|
||||||
}, [gameState, spawnEnemy]);
|
}, [gameState, spawnEnemy, endGame]);
|
||||||
|
|
||||||
const getEnemyStyle = (type: Enemy["type"]) => {
|
|
||||||
switch (type) {
|
|
||||||
case "phishing":
|
|
||||||
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
|
|
||||||
case "legit":
|
|
||||||
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
|
|
||||||
default:
|
|
||||||
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div
|
||||||
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg border border-border bg-card shadow-xl overflow-hidden select-none"
|
||||||
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
|
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
|
<span className="text-sm font-medium text-foreground">Spam Siege</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<X className="w-4 h-4 text-muted-foreground" />
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HUD */}
|
<div className="flex items-center justify-between px-4 py-2 bg-muted/40 border-b border-border text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<span>
|
||||||
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
|
Score <span className="font-medium text-foreground tabular-nums">{score}</span>
|
||||||
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
|
</span>
|
||||||
</div>
|
<span>
|
||||||
<div className="flex items-center gap-1">
|
Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
|
||||||
{[...Array(3)].map((_, i) => (
|
</span>
|
||||||
<Shield
|
|
||||||
key={i}
|
|
||||||
className="w-3.5 h-3.5 transition-colors"
|
|
||||||
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
|
||||||
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
|
|
||||||
strokeWidth={i < shieldHealth ? 0 : 1.5}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span>
|
||||||
|
Misses{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium tabular-nums",
|
||||||
|
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{misses}/{MAX_MISSES}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Game area */}
|
|
||||||
<div
|
<div
|
||||||
className="relative bg-background overflow-hidden"
|
className="relative bg-background overflow-hidden"
|
||||||
style={{ height: GAME_HEIGHT }}
|
style={{ height: GAME_HEIGHT }}
|
||||||
>
|
>
|
||||||
{/* Grid lines for depth */}
|
<div
|
||||||
<div className="absolute inset-0 opacity-[0.03]" style={{
|
className="absolute left-0 right-0 flex items-center gap-2 px-4"
|
||||||
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
|
style={{ top: INBOX_Y }}
|
||||||
backgroundSize: "40px 40px",
|
>
|
||||||
}} />
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
{/* Fortress wall */}
|
Inbox
|
||||||
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
|
</span>
|
||||||
<div className="relative w-full">
|
<div className="h-px flex-1 bg-border" />
|
||||||
{/* Shield centered above the line */}
|
|
||||||
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
|
|
||||||
<Shield
|
|
||||||
className="w-7 h-7 drop-shadow-sm"
|
|
||||||
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
|
||||||
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Solid line */}
|
|
||||||
<div
|
|
||||||
className="h-[2px] w-full"
|
|
||||||
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Subtle gradient fill below */}
|
|
||||||
<div
|
|
||||||
className="flex-1 w-full"
|
|
||||||
style={{
|
|
||||||
background: shieldHealth > 0
|
|
||||||
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
|
|
||||||
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enemies */}
|
|
||||||
{enemies.map((e) => {
|
{enemies.map((e) => {
|
||||||
const style = getEnemyStyle(e.type);
|
const variant =
|
||||||
|
e.type === "phishing"
|
||||||
|
? "text-warning border-warning/40 bg-warning/10 hover:bg-warning/20"
|
||||||
|
: e.type === "legit"
|
||||||
|
? "text-success border-success/40 bg-success/10 hover:bg-success/20"
|
||||||
|
: "text-destructive border-destructive/40 bg-destructive/10 hover:bg-destructive/20";
|
||||||
|
const Icon =
|
||||||
|
e.type === "phishing" ? AlertTriangle : e.type === "legit" ? MailCheck : Mail;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
|
type="button"
|
||||||
style={{
|
className={cn(
|
||||||
left: e.x,
|
"absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer",
|
||||||
top: e.y,
|
"active:scale-95 transition-transform",
|
||||||
backgroundColor: style.bg,
|
variant
|
||||||
border: `1px solid ${style.border}`,
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => handleHover(e)}
|
|
||||||
>
|
|
||||||
{e.type === "phishing" ? (
|
|
||||||
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
|
|
||||||
) : e.type === "legit" ? (
|
|
||||||
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
|
|
||||||
) : (
|
|
||||||
<Mail className="w-4 h-4" style={{ color: style.color }} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
style={{ left: e.x, top: e.y }}
|
||||||
|
onMouseEnter={(ev) => handleClick(ev, e)}
|
||||||
|
onClick={(ev) => handleClick(ev, e)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Destroy effects */}
|
|
||||||
{destroyEffects.map((e) => (
|
|
||||||
<div
|
|
||||||
key={e.id}
|
|
||||||
className="absolute pointer-events-none animate-ping"
|
|
||||||
style={{ left: e.x + 4, top: e.y + 4 }}
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-muted-foreground/50" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Deliver effects (legit mail arrived) */}
|
|
||||||
{deliverEffects.map((e) => (
|
|
||||||
<div
|
|
||||||
key={e.id}
|
|
||||||
className="absolute pointer-events-none animate-ping"
|
|
||||||
style={{ left: e.x + 4, top: e.y - 8 }}
|
|
||||||
>
|
|
||||||
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Hit effects on fortress */}
|
|
||||||
{hitEffects.map((e) => (
|
|
||||||
<div
|
|
||||||
key={e.id}
|
|
||||||
className="absolute pointer-events-none"
|
|
||||||
style={{ left: e.x, top: e.y - 10 }}
|
|
||||||
>
|
|
||||||
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Idle overlay */}
|
|
||||||
{gameState === "idle" && (
|
{gameState === "idle" && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
|
||||||
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
|
<Shield className="w-10 h-10 text-primary" />
|
||||||
<div className="text-center">
|
<div className="space-y-1.5">
|
||||||
<p className="text-base font-semibold text-foreground">Spam Siege</p>
|
<p className="text-base font-medium text-foreground">Spam Siege</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
|
Click spam and phishing before they hit your inbox. Don't block legitimate
|
||||||
|
mail. Three misses and it's over.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
|
||||||
<Shield className="w-3.5 h-3.5 mr-1.5" />
|
<span className="inline-flex items-center gap-1.5">
|
||||||
Defend
|
<Mail className="w-3 h-3 text-destructive" />
|
||||||
|
Spam
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||||
|
Phishing
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MailCheck className="w-3 h-3 text-success" />
|
||||||
|
Legit
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={startGame}>
|
||||||
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Won overlay */}
|
{gameState === "over" && (
|
||||||
{gameState === "won" && (
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
<Shield
|
||||||
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
|
className={cn(
|
||||||
<div className="text-center">
|
"w-10 h-10",
|
||||||
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
|
survived ? "text-success" : "text-muted-foreground/40"
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
)}
|
||||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-base font-medium text-foreground">
|
||||||
|
{survived ? "Inbox held" : "Inbox overrun"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Final score{" "}
|
||||||
|
<span className="font-medium text-foreground tabular-nums">{score}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={onClose}>
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
<Button size="sm" onClick={startGame}>
|
||||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Again
|
Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lost overlay */}
|
|
||||||
{gameState === "lost" && (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
|
||||||
<Shield className="w-14 h-14 text-muted-foreground/40" />
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-1">
|
|
||||||
<Button size="sm" variant="outline" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function TourOverlay() {
|
|||||||
// Wait for target element to appear, then show
|
// Wait for target element to appear, then show
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" - target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
||||||
// instead of disappearing and reappearing
|
// instead of disappearing and reappearing
|
||||||
@@ -178,7 +178,7 @@ export function TourOverlay() {
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
if (attempts >= maxAttempts && !cancelled) {
|
if (attempts >= maxAttempts && !cancelled) {
|
||||||
// Skip this step if element never appears
|
// Skip this step if element never appears
|
||||||
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
|
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED - element never appeared after ${maxAttempts} attempts`);
|
||||||
nextStepRef.current();
|
nextStepRef.current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react";
|
import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface TrustedSendersModalProps {
|
interface TrustedSendersModalProps {
|
||||||
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore();
|
const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
|
||||||
|
const {
|
||||||
|
trustedSenderEmails,
|
||||||
|
trustedSendersLoaded,
|
||||||
|
trustedSendersLoading,
|
||||||
|
loadTrustedSendersBook,
|
||||||
|
addToTrustedSendersBook,
|
||||||
|
removeFromTrustedSendersBook,
|
||||||
|
} = useContactStore();
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const [emailError, setEmailError] = useState("");
|
const [emailError, setEmailError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// When address book mode is on, load the book on first open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||||
|
loadTrustedSendersBook(client);
|
||||||
|
}
|
||||||
|
}, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||||
|
|
||||||
|
// The active list depends on mode
|
||||||
|
const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders;
|
||||||
|
const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading);
|
||||||
|
|
||||||
// Filter senders based on search query
|
// Filter senders based on search query
|
||||||
const filteredSenders = useMemo(() => {
|
const filteredSenders = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return trustedSenders;
|
if (!searchQuery.trim()) return activeSenders;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return trustedSenders.filter((email) => email.toLowerCase().includes(query));
|
return activeSenders.filter((email) => email.toLowerCase().includes(query));
|
||||||
}, [trustedSenders, searchQuery]);
|
}, [activeSenders, searchQuery]);
|
||||||
|
|
||||||
// Show search only when 5+ senders
|
// Show search only when 5+ senders
|
||||||
const showSearch = trustedSenders.length >= 5;
|
const showSearch = activeSenders.length >= 5;
|
||||||
|
|
||||||
// Close on Escape key
|
// Close on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSender = () => {
|
const handleAddSender = async () => {
|
||||||
const trimmedEmail = newEmail.trim().toLowerCase();
|
const trimmedEmail = newEmail.trim().toLowerCase();
|
||||||
|
|
||||||
if (!trimmedEmail) {
|
if (!trimmedEmail) {
|
||||||
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trustedSenders.includes(trimmedEmail)) {
|
if (activeSenders.includes(trimmedEmail)) {
|
||||||
setEmailError(t("already_added"));
|
setEmailError(t("already_added"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTrustedSender(trimmedEmail);
|
setIsSubmitting(true);
|
||||||
setNewEmail("");
|
try {
|
||||||
setIsAdding(false);
|
if (trustedSendersAddressBook && client) {
|
||||||
setEmailError("");
|
await addToTrustedSendersBook(client, trimmedEmail);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(trimmedEmail);
|
||||||
|
}
|
||||||
|
setNewEmail("");
|
||||||
|
setIsAdding(false);
|
||||||
|
setEmailError("");
|
||||||
|
} catch {
|
||||||
|
setEmailError(t("save_error"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSender = async (email: string) => {
|
||||||
|
if (trustedSendersAddressBook && client) {
|
||||||
|
await removeFromTrustedSendersBook(client, email);
|
||||||
|
} else {
|
||||||
|
removeTrustedSender(email);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{trustedSenders.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : activeSenders.length === 0 ? (
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||||
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||||
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
{email}
|
{email}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeTrustedSender(email)}
|
onClick={() => handleRemoveSender(email)}
|
||||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
aria-label={`${t("remove")} ${email}`}
|
aria-label={`${t("remove")} ${email}`}
|
||||||
>
|
>
|
||||||
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer - Add sender */}
|
{/* Footer - Add sender */}
|
||||||
{trustedSenders.length > 0 && (
|
{!isLoading && activeSenders.length > 0 && (
|
||||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddSender}
|
onClick={handleAddSender}
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium"
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("add_button")}
|
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{emailError && (
|
{emailError && (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||||
|
|
||||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
@@ -143,10 +144,26 @@ interface AvatarProps {
|
|||||||
|
|
||||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||||
const contacts = useContactStore((s) => s.contacts);
|
const contacts = useContactStore((s) => s.contacts);
|
||||||
const { devMode } = useConfig();
|
const { devMode } = useConfig();
|
||||||
|
|
||||||
|
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
|
||||||
|
// Runs whenever email or name changes; resets plugin avatar state on each change.
|
||||||
|
useEffect(() => {
|
||||||
|
setPluginAvatarUrl(null);
|
||||||
|
setPluginAvatarFailed(false);
|
||||||
|
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
avatarHooks.onAvatarResolve
|
||||||
|
.transform(null as string | null, { email, name })
|
||||||
|
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
|
||||||
|
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [email, name]);
|
||||||
|
|
||||||
// Look up contact photo by email from the contact store
|
// Look up contact photo by email from the contact store
|
||||||
const resolvedContactPhoto = useMemo(() => {
|
const resolvedContactPhoto = useMemo(() => {
|
||||||
if (contactPhotoUri) return contactPhotoUri;
|
if (contactPhotoUri) return contactPhotoUri;
|
||||||
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
|
|||||||
const showFavicon =
|
const showFavicon =
|
||||||
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||||
|
|
||||||
// Priority: contact photo > custom avatar > profile picture > company favicon > initials
|
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||||
|
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||||
const imgSrc = !imgError && !domainFailed
|
const imgSrc = !imgError && !domainFailed
|
||||||
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||||
: (resolvedContactPhoto || customAvatar || profilePic || null);
|
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||||
|
|
||||||
const handleImgError = useCallback(() => {
|
const handleImgError = useCallback(() => {
|
||||||
|
// If the plugin avatar just failed, mark it and fall through to the next source
|
||||||
|
if (pluginAvatar && imgSrc === pluginAvatar) {
|
||||||
|
setPluginAvatarFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImgError(true);
|
setImgError(true);
|
||||||
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain
|
// If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
|
||||||
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) {
|
if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
|
||||||
failedFaviconDomains.add(faviconDomain);
|
failedFaviconDomains.add(faviconDomain);
|
||||||
}
|
}
|
||||||
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
|
}, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { type SVGProps, type ReactElement } from "react";
|
||||||
|
|
||||||
|
type FlagProps = SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
const flagClass = "inline-block rounded-[2px] shrink-0";
|
||||||
|
const W = 20;
|
||||||
|
const H = 15;
|
||||||
|
|
||||||
|
/** Great Britain – Union Jack (simplified) */
|
||||||
|
export function FlagGB(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="60" height="30" fill="#012169" />
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="2" />
|
||||||
|
<path d="M30,0 V30 M0,15 H60" stroke="#fff" strokeWidth="10" />
|
||||||
|
<path d="M30,0 V30 M0,15 H60" stroke="#C8102E" strokeWidth="6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** France – Blue, White, Red vertical */
|
||||||
|
export function FlagFR(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="1" height="2" fill="#002395" />
|
||||||
|
<rect x="1" width="1" height="2" fill="#fff" />
|
||||||
|
<rect x="2" width="1" height="2" fill="#ED2939" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Japan – White with red circle */
|
||||||
|
export function FlagJP(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="3" height="2" fill="#fff" />
|
||||||
|
<circle cx="1.5" cy="1" r="0.6" fill="#BC002D" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** South Korea – Simplified */
|
||||||
|
export function FlagKR(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="3" height="2" fill="#fff" />
|
||||||
|
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
|
||||||
|
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
|
||||||
|
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spain – Red, Yellow, Red horizontal */
|
||||||
|
export function FlagES(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="3" height="0.5" fill="#AA151B" />
|
||||||
|
<rect y="0.5" width="3" height="1" fill="#F1BF00" />
|
||||||
|
<rect y="1.5" width="3" height="0.5" fill="#AA151B" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Italy – Green, White, Red vertical */
|
||||||
|
export function FlagIT(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="1" height="2" fill="#009246" />
|
||||||
|
<rect x="1" width="1" height="2" fill="#fff" />
|
||||||
|
<rect x="2" width="1" height="2" fill="#CE2B37" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Germany – Black, Red, Gold horizontal */
|
||||||
|
export function FlagDE(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="5" height="1" fill="#000" />
|
||||||
|
<rect y="1" width="5" height="1" fill="#DD0000" />
|
||||||
|
<rect y="2" width="5" height="1" fill="#FFCC00" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latvia – Maroon, White, Maroon horizontal */
|
||||||
|
export function FlagLV(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="20" height="4" fill="#9E3039" />
|
||||||
|
<rect y="4" width="20" height="2" fill="#fff" />
|
||||||
|
<rect y="6" width="20" height="4" fill="#9E3039" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Netherlands – Red, White, Blue horizontal */
|
||||||
|
export function FlagNL(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="9" height="2" fill="#AE1C28" />
|
||||||
|
<rect y="2" width="9" height="2" fill="#fff" />
|
||||||
|
<rect y="4" width="9" height="2" fill="#21468B" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poland – White, Red horizontal */
|
||||||
|
export function FlagPL(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 5" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="8" height="2.5" fill="#fff" />
|
||||||
|
<rect y="2.5" width="8" height="2.5" fill="#DC143C" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brazil – Green, yellow diamond (simplified) */
|
||||||
|
export function FlagBR(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 14" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="20" height="14" fill="#009B3A" />
|
||||||
|
<polygon points="10,1.5 18.5,7 10,12.5 1.5,7" fill="#FEDF00" />
|
||||||
|
<circle cx="10" cy="7" r="3" fill="#002776" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Russia – White, Blue, Red horizontal */
|
||||||
|
export function FlagRU(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="9" height="2" fill="#fff" />
|
||||||
|
<rect y="2" width="9" height="2" fill="#0039A6" />
|
||||||
|
<rect y="4" width="9" height="2" fill="#D52B1E" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ukraine – Blue, Yellow horizontal */
|
||||||
|
export function FlagUA(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="3" height="1" fill="#005BBB" />
|
||||||
|
<rect y="1" width="3" height="1" fill="#FFD500" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** China – Red with yellow stars (simplified) */
|
||||||
|
export function FlagCN(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="30" height="20" fill="#DE2910" />
|
||||||
|
<g fill="#FFDE00">
|
||||||
|
<polygon points="5,2 6,5 3.2,3.2 6.8,3.2 4,5" />
|
||||||
|
<polygon points="10,1 10.6,2.7 9,1.8 11,1.8 9.4,2.7" />
|
||||||
|
<polygon points="12,3 12.6,4.7 11,3.8 13,3.8 11.4,4.7" />
|
||||||
|
<polygon points="12,6 12.6,7.7 11,6.8 13,6.8 11.4,7.7" />
|
||||||
|
<polygon points="10,8 10.6,9.7 9,8.8 11,8.8 9.4,9.7" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map locale codes to flag components */
|
||||||
|
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||||
|
en: FlagGB,
|
||||||
|
fr: FlagFR,
|
||||||
|
ja: FlagJP,
|
||||||
|
ko: FlagKR,
|
||||||
|
es: FlagES,
|
||||||
|
it: FlagIT,
|
||||||
|
de: FlagDE,
|
||||||
|
lv: FlagLV,
|
||||||
|
nl: FlagNL,
|
||||||
|
pl: FlagPL,
|
||||||
|
pt: FlagBR,
|
||||||
|
ru: FlagRU,
|
||||||
|
uk: FlagUA,
|
||||||
|
zh: FlagCN,
|
||||||
|
};
|
||||||
@@ -1,36 +1,110 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import { useLocaleStore } from '@/stores/locale-store';
|
import { useLocaleStore } from '@/stores/locale-store';
|
||||||
import { Select } from '@/components/settings/settings-section';
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { flagComponents } from './flag-icons';
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'fr', label: 'Français' },
|
||||||
|
{ value: 'ja', label: '日本語' },
|
||||||
|
{ value: 'ko', label: '한국어' },
|
||||||
|
{ value: 'es', label: 'Español' },
|
||||||
|
{ value: 'it', label: 'Italiano' },
|
||||||
|
{ value: 'de', label: 'Deutsch' },
|
||||||
|
{ value: 'lv', label: 'Latviešu' },
|
||||||
|
{ value: 'nl', label: 'Nederlands' },
|
||||||
|
{ value: 'pl', label: 'Polski' },
|
||||||
|
{ value: 'pt', label: 'Português' },
|
||||||
|
{ value: 'ru', label: 'Русский' },
|
||||||
|
{ value: 'uk', label: 'Українська' },
|
||||||
|
{ value: 'zh', label: '简体中文' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FlagIcon({ locale }: { locale: string }) {
|
||||||
|
const Flag = flagComponents[locale];
|
||||||
|
if (!Flag) return null;
|
||||||
|
return <Flag />;
|
||||||
|
}
|
||||||
|
|
||||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||||
const currentLocale = useLocale();
|
const currentLocale = useLocale();
|
||||||
const setLocale = useLocaleStore((state) => state.setLocale);
|
const setLocale = useLocaleStore((state) => state.setLocale);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
const languages = [
|
const current = languages.find((l) => l.value === currentLocale) ?? languages[0];
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
{ value: 'fr', label: 'Français' },
|
// Close on outside click
|
||||||
{ value: 'ja', label: '日本語' },
|
useEffect(() => {
|
||||||
{ value: 'ko', label: '한국어' },
|
if (!open) return;
|
||||||
{ value: 'es', label: 'Español' },
|
function handleClick(e: MouseEvent) {
|
||||||
{ value: 'it', label: 'Italiano' },
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
{ value: 'de', label: 'Deutsch' },
|
setOpen(false);
|
||||||
{ value: 'lv', label: 'Latviešu' },
|
}
|
||||||
{ value: 'nl', label: 'Nederlands' },
|
}
|
||||||
{ value: 'pl', label: 'Polski' },
|
document.addEventListener("mousedown", handleClick);
|
||||||
{ value: 'pt', label: 'Português' },
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
{ value: 'ru', label: 'Русский' },
|
}, [open]);
|
||||||
{ value: 'zh', label: '简体中文' }
|
|
||||||
];
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div ref={containerRef} className={cn("relative", className)}>
|
||||||
<Select
|
<button
|
||||||
value={currentLocale}
|
type="button"
|
||||||
onChange={setLocale}
|
onClick={() => setOpen((v) => !v)}
|
||||||
options={languages}
|
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer w-full"
|
||||||
/>
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<FlagIcon locale={current.value} />
|
||||||
|
<span className="flex-1 text-left">{current.label}</span>
|
||||||
|
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-150", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
aria-activedescendant={`lang-${currentLocale}`}
|
||||||
|
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-background shadow-lg py-1"
|
||||||
|
>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<li
|
||||||
|
key={lang.value}
|
||||||
|
id={`lang-${lang.value}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={lang.value === currentLocale}
|
||||||
|
onClick={() => {
|
||||||
|
setLocale(lang.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-100",
|
||||||
|
lang.value === currentLocale
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground hover:bg-accent/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FlagIcon locale={lang.value} />
|
||||||
|
<span>{lang.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ export function useBrowserNavigation({
|
|||||||
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
|
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
// Hold the "applying pop" flag for the entire restore — including any
|
// Hold the "applying pop" flag for the entire restore - including any
|
||||||
// async work like fetching email content — so the resulting state
|
// async work like fetching email content - so the resulting state
|
||||||
// updates don't trigger a fresh history push that would undo the
|
// updates don't trigger a fresh history push that would undo the
|
||||||
// user's back / forward navigation.
|
// user's back / forward navigation.
|
||||||
popDepthRef.current += 1;
|
popDepthRef.current += 1;
|
||||||
@@ -147,9 +147,38 @@ export function useBrowserNavigation({
|
|||||||
|
|
||||||
if (!initializedRef.current) {
|
if (!initializedRef.current) {
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
// Replace the current entry on the very first run so we don't
|
|
||||||
// create an extra step the user has to back through to leave the app.
|
if (emailId || threadId) {
|
||||||
window.history.replaceState(newState, "");
|
// The app is initializing directly on an email/thread view (e.g. the
|
||||||
|
// user navigated here from /settings or an external link). Seed a
|
||||||
|
// "list" history entry first so that the toolbar back button returns
|
||||||
|
// to the list instead of leaving the app entirely.
|
||||||
|
const listSnapshot: NavSnapshot = {
|
||||||
|
mailboxId,
|
||||||
|
emailId: null,
|
||||||
|
threadId: null,
|
||||||
|
composerOpen: false,
|
||||||
|
sidebarOpen,
|
||||||
|
};
|
||||||
|
const listStored: StoredNavState = {
|
||||||
|
...listSnapshot,
|
||||||
|
navId: ++navIdCounter,
|
||||||
|
};
|
||||||
|
const baseState = (window.history.state ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
window.history.replaceState(
|
||||||
|
{ ...baseState, [STATE_KEY]: listStored },
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
// Now push the actual email state on top of the synthetic list entry.
|
||||||
|
window.history.pushState(newState, "");
|
||||||
|
} else {
|
||||||
|
// Replace the current entry on the very first run so we don't
|
||||||
|
// create an extra step the user has to back through to leave the app.
|
||||||
|
window.history.replaceState(newState, "");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.history.pushState(newState, "");
|
window.history.pushState(newState, "");
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigData {
|
interface ConfigData {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -50,7 +51,7 @@ export async function fetchConfig(): Promise<ConfigData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start a new fetch
|
// Start a new fetch
|
||||||
configPromise = fetch('/api/config')
|
configPromise = apiFetch('/api/config')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Failed to fetch config');
|
throw new Error('Failed to fetch config');
|
||||||
|
|||||||
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
|||||||
const email = currentEmails.find(em => em.id === emailId);
|
const email = currentEmails.find(em => em.id === emailId);
|
||||||
const keywords = { ...(email?.keywords || {}) };
|
const keywords = { ...(email?.keywords || {}) };
|
||||||
|
|
||||||
// Remove old label/color keywords
|
// Add the tag without removing existing ones
|
||||||
Object.keys(keywords).forEach(key => {
|
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
|
||||||
keywords[key] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the new tag
|
|
||||||
keywords[`$label:${tagId}`] = true;
|
keywords[`$label:${tagId}`] = true;
|
||||||
|
|
||||||
await client.updateEmailKeywords(emailId, keywords);
|
await client.updateEmailKeywords(emailId, keywords);
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
case 'ru':
|
case 'ru':
|
||||||
messages = (await import('../locales/ru/common.json')).default;
|
messages = (await import('../locales/ru/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'uk':
|
||||||
|
messages = (await import('../locales/uk/common.json')).default;
|
||||||
|
break;
|
||||||
case 'zh':
|
case 'zh':
|
||||||
messages = (await import('../locales/zh/common.json')).default;
|
messages = (await import('../locales/zh/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
|||||||
+14
-2
@@ -1,9 +1,21 @@
|
|||||||
import { defineRouting } from 'next-intl/routing';
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
|
// Locale prefix mode can be configured via NEXT_PUBLIC_LOCALE_PREFIX.
|
||||||
|
// - "never" (default): /settings - locale from cookie/Accept-Language
|
||||||
|
// - "always": /en/settings - locale always in the URL
|
||||||
|
// - "as-needed": /settings for default locale, /fr/settings otherwise
|
||||||
|
// When proxying Bulwark under a sub-path (NEXT_PUBLIC_BASE_PATH), "always" is
|
||||||
|
// recommended to avoid next-intl rewrite loops caused by locale detection
|
||||||
|
// conflicting with the proxy's path rewriting.
|
||||||
|
const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||||
|
| 'never'
|
||||||
|
| 'always'
|
||||||
|
| 'as-needed';
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'zh'],
|
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
localePrefix: 'never'
|
localePrefix
|
||||||
});
|
});
|
||||||
|
|
||||||
export const locales = routing.locales;
|
export const locales = routing.locales;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ if (process.env.NODE_ENV === "production") {
|
|||||||
if (!SEMVER_RE.test(remote)) return;
|
if (!SEMVER_RE.test(remote)) return;
|
||||||
if (compareVersions(current, remote) > 0) {
|
if (compareVersions(current, remote) > 0) {
|
||||||
console.info(
|
console.info(
|
||||||
`Update available: v${remote} — https://github.com/bulwarkmail/webmail`
|
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { unlink, writeFileSync } from "fs";
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
// Mock NextResponse before importing the route
|
// Mock NextResponse before importing the route
|
||||||
@@ -24,6 +25,7 @@ describe('config API route', () => {
|
|||||||
delete process.env.OAUTH_CLIENT_ID;
|
delete process.env.OAUTH_CLIENT_ID;
|
||||||
delete process.env.OAUTH_ISSUER_URL;
|
delete process.env.OAUTH_ISSUER_URL;
|
||||||
delete process.env.SESSION_SECRET;
|
delete process.env.SESSION_SECRET;
|
||||||
|
delete process.env.SESSION_SECRET_FILE;
|
||||||
delete process.env.SETTINGS_SYNC_ENABLED;
|
delete process.env.SETTINGS_SYNC_ENABLED;
|
||||||
delete process.env.STALWART_FEATURES;
|
delete process.env.STALWART_FEATURES;
|
||||||
delete process.env.DEV_MOCK_JMAP;
|
delete process.env.DEV_MOCK_JMAP;
|
||||||
@@ -128,6 +130,19 @@ describe('config API route', () => {
|
|||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
|
expect(config.rememberMeEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
|
||||||
|
writeFileSync('./session-secret', 'test-secret');
|
||||||
|
process.env.SESSION_SECRET_FILE = './session-secret';
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
unlink('./session-secret', (err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
});
|
||||||
|
|
||||||
expect(config.rememberMeEnabled).toBe(true);
|
expect(config.rememberMeEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +153,23 @@ describe('config API route', () => {
|
|||||||
|
|
||||||
process.env.SESSION_SECRET = 'test-secret';
|
process.env.SESSION_SECRET = 'test-secret';
|
||||||
const config2 = await getConfig();
|
const config2 = await getConfig();
|
||||||
|
expect(config2.settingsSyncEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
|
||||||
|
process.env.SETTINGS_SYNC_ENABLED = 'true';
|
||||||
|
const config1 = await getConfig();
|
||||||
|
expect(config1.settingsSyncEnabled).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync('./session-secret', 'test-secret');
|
||||||
|
process.env.SESSION_SECRET_FILE = './session-secret';
|
||||||
|
|
||||||
|
const config2 = await getConfig();
|
||||||
|
|
||||||
|
unlink('./session-secret', (err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
});
|
||||||
|
|
||||||
expect(config2.settingsSyncEnabled).toBe(true);
|
expect(config2.settingsSyncEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Mailbox/get', () => {
|
describe('POST /api - Mailbox/get', () => {
|
||||||
it('should return list of mailboxes', async () => {
|
it('should return list of mailboxes', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -88,7 +88,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Email/query', () => {
|
describe('POST /api - Email/query', () => {
|
||||||
it('should filter by mailbox', async () => {
|
it('should filter by mailbox', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -117,7 +117,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Email/get', () => {
|
describe('POST /api - Email/get', () => {
|
||||||
it('should return emails by ids', async () => {
|
it('should return emails by ids', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -150,7 +150,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Email/set', () => {
|
describe('POST /api - Email/set', () => {
|
||||||
it('should update email keywords', async () => {
|
it('should update email keywords', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -166,7 +166,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Identity/get', () => {
|
describe('POST /api - Identity/get', () => {
|
||||||
it('should return identities', async () => {
|
it('should return identities', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -183,7 +183,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — unknown method', () => {
|
describe('POST /api - unknown method', () => {
|
||||||
it('should return error for unknown methods', async () => {
|
it('should return error for unknown methods', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -199,7 +199,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — back-references', () => {
|
describe('POST /api - back-references', () => {
|
||||||
it('should resolve #ids from Email/query result', async () => {
|
it('should resolve #ids from Email/query result', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -220,7 +220,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — invalid request', () => {
|
describe('POST /api - invalid request', () => {
|
||||||
it('should return 400 for missing methodCalls', async () => {
|
it('should return 400 for missing methodCalls', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('authenticatedFetch — network error retry', () => {
|
describe('authenticatedFetch - network error retry', () => {
|
||||||
it('retries once on transient network error', async () => {
|
it('retries once on transient network error', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
@@ -95,7 +95,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authenticatedFetch — basic auth 401 session refresh', () => {
|
describe('authenticatedFetch - basic auth 401 session refresh', () => {
|
||||||
it('refreshes session and retries on 401 for API requests', async () => {
|
it('refreshes session and retries on 401 for API requests', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' });
|
const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' });
|
||||||
@@ -138,12 +138,12 @@ describe('JMAPClient resilience', () => {
|
|||||||
|
|
||||||
// connect() should throw without trying to refresh session (would cause infinite recursion)
|
// connect() should throw without trying to refresh session (would cause infinite recursion)
|
||||||
await expect(client.connect()).rejects.toThrow('Invalid username or password');
|
await expect(client.connect()).rejects.toThrow('Invalid username or password');
|
||||||
// Only one fetch call — no refresh attempt
|
// Only one fetch call - no refresh attempt
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authenticatedFetch — bearer token refresh', () => {
|
describe('authenticatedFetch - bearer token refresh', () => {
|
||||||
it('refreshes token and retries on 401 for bearer mode', async () => {
|
it('refreshes token and retries on 401 for bearer mode', async () => {
|
||||||
const tokenRefresh = vi.fn().mockResolvedValue('new-token-456');
|
const tokenRefresh = vi.fn().mockResolvedValue('new-token-456');
|
||||||
const session = makeSession();
|
const session = makeSession();
|
||||||
@@ -172,7 +172,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authenticatedFetch — 429 rate limiting', () => {
|
describe('authenticatedFetch - 429 rate limiting', () => {
|
||||||
it('stops sending authenticated requests until the retry window expires', async () => {
|
it('stops sending authenticated requests until the retry window expires', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
// So ping throws, keep-alive catches it, fires false
|
// So ping throws, keep-alive catches it, fires false
|
||||||
// Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds
|
// Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds
|
||||||
fetchSpy
|
fetchSpy
|
||||||
// ping fails — network error, retry also fails
|
// ping fails - network error, retry also fails
|
||||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
// reconnect → connect() → session URL succeeds
|
// reconnect → connect() → session URL succeeds
|
||||||
|
|||||||
@@ -267,13 +267,13 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
||||||
// Reporter's exact scenario: two subfolders with the same name.
|
// Reporter's exact scenario: two subfolders with the same name.
|
||||||
// The dedup uses substring matching and removes non-role folders whose name
|
// The dedup uses substring matching and removes non-role folders whose name
|
||||||
// matches a role folder — even if they're deep in the tree with children.
|
// matches a role folder - even if they're deep in the tree with children.
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
// User-created subfolder also named "Sent" nested under Inbox
|
// User-created subfolder also named "Sent" nested under Inbox
|
||||||
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
||||||
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
|
// Child of the custom "Sent" folder - becomes orphaned if parent is deduped
|
||||||
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
const flat = flattenMailboxTree(tree);
|
const flat = flattenMailboxTree(tree);
|
||||||
const rootIds = tree.map(n => n.id);
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
// sent-custom MUST be kept because it has children — removing it orphans sent-child
|
// sent-custom MUST be kept because it has children - removing it orphans sent-child
|
||||||
const sentCustom = flat.find(n => n.id === 'sent-custom');
|
const sentCustom = flat.find(n => n.id === 'sent-custom');
|
||||||
expect(sentCustom).toBeDefined();
|
expect(sentCustom).toBeDefined();
|
||||||
expect(sentCustom!.depth).toBe(1); // nested under Inbox
|
expect(sentCustom!.depth).toBe(1); // nested under Inbox
|
||||||
@@ -293,7 +293,7 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should keep nested folders when name is substring of a role name', () => {
|
it('should keep nested folders when name is substring of a role name', () => {
|
||||||
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
|
// "Draft" is a substring of "Drafts" - dedup removes it, orphaning children
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
||||||
@@ -343,22 +343,22 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
|
|
||||||
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
|
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
|
||||||
// Dedup should only remove mailboxes that are BOTH:
|
// Dedup should only remove mailboxes that are BOTH:
|
||||||
// 1. At root level (no parentId) — same structural position as role mailbox
|
// 1. At root level (no parentId) - same structural position as role mailbox
|
||||||
// 2. Name-matching a role mailbox
|
// 2. Name-matching a role mailbox
|
||||||
// Nested mailboxes with matching names should always be kept.
|
// Nested mailboxes with matching names should always be kept.
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
|
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate - OK to remove
|
||||||
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
|
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
|
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested - must keep
|
||||||
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
|
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const flat = flattenMailboxTree(tree);
|
const flat = flattenMailboxTree(tree);
|
||||||
|
|
||||||
// "Sent Mail" at root (no parentId) can be deduped — that's fine
|
// "Sent Mail" at root (no parentId) can be deduped - that's fine
|
||||||
// But "Sent" nested under Projects must be kept
|
// But "Sent" nested under Projects must be kept
|
||||||
const sentNested = flat.find(n => n.id === 'sent-nested');
|
const sentNested = flat.find(n => n.id === 'sent-nested');
|
||||||
expect(sentNested).toBeDefined();
|
expect(sentNested).toBeDefined();
|
||||||
@@ -377,7 +377,7 @@ describe('mailbox orphan behavior (missing parent)', () => {
|
|||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
// 'bookings' is MISSING — simulating truncated JMAP response
|
// 'bookings' is MISSING - simulating truncated JMAP response
|
||||||
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('oauth/discovery', () => {
|
|||||||
expect(consoleSpy).toHaveBeenCalled();
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caches results — second call for same server URL does not re-fetch', async () => {
|
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(VALID_METADATA),
|
json: () => Promise.resolve(VALID_METADATA),
|
||||||
|
|||||||
@@ -196,10 +196,15 @@ describe('sieve generator', () => {
|
|||||||
expect(result.vacation?.isEnabled).toBe(true);
|
expect(result.vacation?.isEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should mark as opaque when real filter rules exist alongside vacation', () => {
|
it('parses filter rules alongside vacation as external when no metadata is present', () => {
|
||||||
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
|
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
|
||||||
const result = parseScript(script);
|
const result = parseScript(script);
|
||||||
expect(result.isOpaque).toBe(true);
|
// New behavior: preserve both the vacation statement (as opaque) and
|
||||||
|
// the if-block (as a structured external rule) instead of dropping them.
|
||||||
|
expect(result.isOpaque).toBe(false);
|
||||||
|
const ifRule = result.rules.find(r => r.origin === 'external');
|
||||||
|
expect(ifRule?.conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'boss@example.com' });
|
||||||
|
expect(ifRule?.actions[0]).toEqual({ type: 'move', value: 'Important' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Stalwart :mime format vacation script', () => {
|
it('should handle Stalwart :mime format vacation script', () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user