Compare commits
195
Commits
1.7.7
..
vnc-v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6fdfe72ca | ||
|
|
3afa7ce012 | ||
|
|
759ab7fe8c | ||
|
|
fb40e74713 | ||
|
|
fe77e9f52b | ||
|
|
a9af816012 | ||
|
|
90c1176f93 | ||
|
|
5c1f14fa8b | ||
|
|
a4155aa342 | ||
|
|
d047891ded | ||
|
|
bc5d2a57e8 | ||
|
|
f7e487171c | ||
|
|
e9746fcf78 | ||
|
|
d91db37b34 | ||
|
|
ae19ad888b | ||
|
|
f0e63de09b | ||
|
|
88670d4bcf | ||
|
|
1bc5a6a0ce | ||
|
|
be7bfd1f02 | ||
|
|
22e5e97da9 | ||
|
|
476c76c420 | ||
|
|
a3d551b640 | ||
|
|
83a9c5a809 | ||
|
|
fb4e8f3591 | ||
|
|
0189935e7b | ||
|
|
eace443fdf | ||
|
|
e94a1429d5 | ||
|
|
15982c2468 | ||
|
|
d15c58f8da | ||
|
|
6698ec8456 | ||
|
|
e738941950 | ||
|
|
1890cade08 | ||
|
|
fc7fec44a8 | ||
|
|
1652a0ec62 | ||
|
|
e659fe3d38 | ||
|
|
348e032dce | ||
|
|
a6c15b8ad6 | ||
|
|
5a2bc6b671 | ||
|
|
59bc7fd64c | ||
|
|
aa7a814b86 | ||
|
|
c5c6509867 | ||
|
|
1cdbf75270 | ||
|
|
ea7892b497 | ||
|
|
d52dfebad4 | ||
|
|
7bb58f4f9f | ||
|
|
f1e1ed1df7 | ||
|
|
d9d9f91a86 | ||
|
|
108406a885 | ||
|
|
013ef7d557 | ||
|
|
56d11b5759 | ||
|
|
0c1e238223 | ||
|
|
ca0ba818b7 | ||
|
|
ce97a54aaa | ||
|
|
b605b6d49d | ||
|
|
cea3ec0fe6 | ||
|
|
f56ca594dc | ||
|
|
66ff523553 | ||
|
|
7969fd09eb | ||
|
|
3108b2f336 | ||
|
|
71565e9328 | ||
|
|
edd11ac27b | ||
|
|
f81b02ead9 | ||
|
|
25e542867b | ||
|
|
0a1114f710 | ||
|
|
3bfa73f375 | ||
|
|
fc50e5b569 | ||
|
|
a34314cef5 | ||
|
|
0563e88eb8 | ||
|
|
7a483b3dd4 | ||
|
|
351f2d4e26 | ||
|
|
f3b6819463 | ||
|
|
3ea22161d9 | ||
|
|
246df49c03 | ||
|
|
9c04950a94 | ||
|
|
934967b9df | ||
|
|
755201c92a | ||
|
|
9b69d89dfd | ||
|
|
17b69e68f3 | ||
|
|
83cd675ccf | ||
|
|
ddcab88e56 | ||
|
|
f188e29152 | ||
|
|
b48b6e0871 | ||
|
|
6248bb9825 | ||
|
|
15ad783848 | ||
|
|
fc116a8b2f | ||
|
|
a16478ffad | ||
|
|
144d6503cc | ||
|
|
9c6292b4b7 | ||
|
|
457063a48b | ||
|
|
21ea5009f4 | ||
|
|
74f9335e36 | ||
|
|
010f082c73 | ||
|
|
00dc509e60 | ||
|
|
af2b00dd35 | ||
|
|
e442c55931 | ||
|
|
2245ad2024 | ||
|
|
7511d8ea78 | ||
|
|
7bf62e4bdc | ||
|
|
959d4bd6ce | ||
|
|
b7c8cd999e | ||
|
|
813185e58d | ||
|
|
3f22a3323a | ||
|
|
0e4efb5a2a | ||
|
|
b354319b82 | ||
|
|
5a8c69dac2 | ||
|
|
24056e4698 | ||
|
|
5818e60401 | ||
|
|
7beaf991e8 | ||
|
|
5105e000f5 | ||
|
|
66bc10fa0f | ||
|
|
07c473e057 | ||
|
|
0e47c3b039 | ||
|
|
e1a973663f | ||
|
|
de55fb6b73 | ||
|
|
a909593dda | ||
|
|
4ad9267a2d | ||
|
|
23a017d4b7 | ||
|
|
c7250dc921 | ||
|
|
80f76abc38 | ||
|
|
adb8686293 | ||
|
|
d531ad1930 | ||
|
|
953355d2a5 | ||
|
|
8155f98a28 | ||
|
|
fda898fc96 | ||
|
|
3dceecb4c5 | ||
|
|
53461d1142 | ||
|
|
4d9d992f3f | ||
|
|
162e420a1f | ||
|
|
e8f01871c2 | ||
|
|
6dfcb07b9a | ||
|
|
0f3459c2e5 | ||
|
|
d5017a211f | ||
|
|
f51ec50443 | ||
|
|
f2703bcc27 | ||
|
|
cda4dcbf01 | ||
|
|
f15edd336b | ||
|
|
a3f9055541 | ||
|
|
a779e101e6 | ||
|
|
c38bcc4a95 | ||
|
|
5716d91115 | ||
|
|
2f791318df | ||
|
|
60b9ae66ea | ||
|
|
abd493fb4c | ||
|
|
b4739c111f | ||
|
|
88b07a1713 | ||
|
|
18e9cf6ee6 | ||
|
|
2cb5c739b4 | ||
|
|
0a30b2fb3a | ||
|
|
0b62afb0f8 | ||
|
|
f749ee1f2a | ||
|
|
4a4950c3e5 | ||
|
|
739b72d251 | ||
|
|
682e47c970 | ||
|
|
a6d8671306 | ||
|
|
6f278845f3 | ||
|
|
334fdbfb86 | ||
|
|
578339c400 | ||
|
|
8d8bc7cb13 | ||
|
|
4dc76bbb47 | ||
|
|
04444003b2 | ||
|
|
7da3d4ae80 | ||
|
|
cb5d754113 | ||
|
|
511f9e5195 | ||
|
|
996fa7eea6 | ||
|
|
01e5cd69cf | ||
|
|
20d02214df | ||
|
|
432ba0516b | ||
|
|
37152504b4 | ||
|
|
9072bf8470 | ||
|
|
b1f6758f98 | ||
|
|
a679d82cc3 | ||
|
|
a08a9e9ed3 | ||
|
|
622adc34de | ||
|
|
4a3394cf8c | ||
|
|
a8598db44d | ||
|
|
d3addf54b4 | ||
|
|
e1e83c4a83 | ||
|
|
cdb31634a6 | ||
|
|
26c3d07d56 | ||
|
|
c3acb537d0 | ||
|
|
060c5d00d1 | ||
|
|
e05fbb2fe9 | ||
|
|
b8809c2e69 | ||
|
|
bc11450f3f | ||
|
|
034f7a4b9b | ||
|
|
2e42693228 | ||
|
|
fdad60cf03 | ||
|
|
dc72122ed8 | ||
|
|
7c221c4a4a | ||
|
|
c1acf58c5f | ||
|
|
42798c2b7c | ||
|
|
75d17d4e37 | ||
|
|
38a396d150 | ||
|
|
c6bd5f645a | ||
|
|
65cb6be8a9 |
@@ -9,3 +9,8 @@ scripts/
|
||||
TODO.md
|
||||
*.md
|
||||
!README.md
|
||||
# Sibling projects / test harness - not part of the webmail image
|
||||
examples/
|
||||
integration/
|
||||
e2e/
|
||||
**/node_modules
|
||||
|
||||
@@ -39,6 +39,16 @@ SETTINGS_SYNC_ENABLED=true
|
||||
LOG_FORMAT=text
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# =============================================================================
|
||||
# Plugin Development
|
||||
# =============================================================================
|
||||
|
||||
# Load plugins from a directory on disk instead of installing them as ZIPs.
|
||||
# Each immediate subfolder is one plugin and needs a manifest.json. When the
|
||||
# manifest's entrypoint exists under src/, it's bundled on demand with esbuild,
|
||||
# so you can edit sources and just refresh the browser.
|
||||
# PLUGIN_DEV_DIR=../my-plugins
|
||||
|
||||
# =============================================================================
|
||||
# Login Page Customization (optional)
|
||||
# =============================================================================
|
||||
|
||||
+162
-1
@@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
|
||||
# Offer several JMAP servers on the login form. JSON array; each entry needs
|
||||
# id, label, and url. "domains" and a per-server "oauth" block are optional.
|
||||
# Prefer configuring this from the admin dashboard - the env form exists for
|
||||
# stateless deployments.
|
||||
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
|
||||
|
||||
# Pick the server automatically from the domain of the address the user types,
|
||||
# matching against each entry's "domains" list. Default: false.
|
||||
# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true
|
||||
|
||||
# =============================================================================
|
||||
# Stalwart Mail Server Integration
|
||||
# =============================================================================
|
||||
@@ -59,6 +69,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# Replace the scopes requested at authorization. Space-separated. Leave unset
|
||||
# to use the defaults the client already asks for.
|
||||
# OAUTH_SCOPES=openid email profile offline_access
|
||||
|
||||
# Append scopes instead of replacing them. Use this when your IdP needs one
|
||||
# extra scope and you don't want to restate the defaults.
|
||||
# OAUTH_EXTRA_SCOPES=groups
|
||||
|
||||
# Send the user straight to the identity provider, skipping the login form.
|
||||
# Intended for embedded deployments where the parent app already authenticated
|
||||
# them. Default: false.
|
||||
# AUTO_SSO_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
@@ -132,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# so the instance id and consent choice survive upgrades.
|
||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||
|
||||
# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset.
|
||||
# BULWARK_TELEMETRY_DISABLED=1
|
||||
|
||||
# Let heartbeats reach a private/loopback address. Off by default as an SSRF
|
||||
# guard; only useful when running a collector locally during development.
|
||||
# BULWARK_TELEMETRY_ALLOW_PRIVATE=1
|
||||
|
||||
# Report a fixed Stalwart version instead of probing the JMAP server's Server
|
||||
# header. Useful when a proxy strips that header.
|
||||
# STALWART_VERSION=0.16.0
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
@@ -197,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Should match your app's main background color. Default: #ffffff
|
||||
# PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
# Screenshots shown in the browser's install prompt. Absolute URLs or paths
|
||||
# relative to public/. Both are optional; per-domain overrides are available
|
||||
# through DOMAIN_BRANDING.
|
||||
# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png
|
||||
# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logos
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# Cap the login logo's rendered size. Any CSS length ("120px", "8rem").
|
||||
# Unset means the logo renders at its natural size.
|
||||
# LOGIN_LOGO_MAX_HEIGHT=96px
|
||||
# LOGIN_LOGO_MAX_WIDTH=320px
|
||||
|
||||
# Hide parts of the login page. All default to true.
|
||||
# Turn the heading and subtitle off when the logo already reads as the brand.
|
||||
# LOGIN_SHOW_HEADING=false
|
||||
# LOGIN_SHOW_SUBTITLE=false
|
||||
#
|
||||
# Hide the optional TOTP field. A server that requires TOTP (totp_required)
|
||||
# still shows it regardless of this setting.
|
||||
# LOGIN_SHOW_TOTP=false
|
||||
#
|
||||
# Hide the version number, so it isn't disclosed to unauthenticated visitors.
|
||||
# LOGIN_SHOW_VERSION=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-domain branding overrides (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,6 +323,108 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Admin Dashboard Access
|
||||
# =============================================================================
|
||||
|
||||
# Bootstrap password for the admin dashboard. Read only when admin.json does
|
||||
# not already exist; the app hashes it, writes admin.json, and logs a warning
|
||||
# telling you to remove this variable. Without it (and without the setup
|
||||
# wizard) the admin dashboard stays disabled.
|
||||
# Accepts a plaintext password or an existing hash.
|
||||
# ADMIN_PASSWORD=change-me
|
||||
|
||||
# Admin session lifetime in seconds. Default: 3600 (1 hour).
|
||||
# ADMIN_SESSION_TTL=3600
|
||||
|
||||
# How many trusted reverse proxies sit in front of the app. The client IP is
|
||||
# taken that many entries from the right of X-Forwarded-For, so an attacker
|
||||
# can't spoof it by prepending values. Default: 1.
|
||||
# TRUSTED_PROXY_DEPTH=2
|
||||
|
||||
# Allow search engines to index the app (robots.txt / noindex). Default: false.
|
||||
# SEARCH_ENGINE_INDEXING=true
|
||||
|
||||
# =============================================================================
|
||||
# Cookies, Embedding & Reverse Proxies
|
||||
# =============================================================================
|
||||
|
||||
# SameSite attribute for session cookies: lax (default), strict, or none.
|
||||
# Embedding the app cross-origin in an iframe requires "none".
|
||||
# COOKIE_SAME_SITE=none
|
||||
|
||||
# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or
|
||||
# COOKIE_SAME_SITE=none. Set to false only for local HTTP development.
|
||||
# COOKIE_SECURE=false
|
||||
|
||||
# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none',
|
||||
# which blocks all framing. Space-separate multiple origins.
|
||||
# ALLOWED_FRAME_ANCESTORS=https://portal.example.com
|
||||
|
||||
# Origin of the parent page when embedded, used for postMessage handshakes.
|
||||
# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com
|
||||
|
||||
# =============================================================================
|
||||
# Update Check
|
||||
# =============================================================================
|
||||
|
||||
# The app periodically checks for new releases and shows a notice. Set to
|
||||
# "off" (or false/0/no) to disable the check entirely.
|
||||
# BULWARK_UPDATE_CHECK=off
|
||||
|
||||
# Override the endpoint it checks. Takes priority over the on-disk state file.
|
||||
# An explicit empty value also disables the check.
|
||||
# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json
|
||||
|
||||
# Where the check stores its state. Default: ./data/version-check
|
||||
# VERSION_CHECK_DATA_DIR=./data/version-check
|
||||
|
||||
# =============================================================================
|
||||
# Translation Proxy (optional)
|
||||
# =============================================================================
|
||||
|
||||
# /api/translate defaults to the public MyMemory API, which needs no setup.
|
||||
# Point it at a LibreTranslate instance instead to keep message text on
|
||||
# infrastructure you control. LibreTranslate also auto-detects the source
|
||||
# language natively.
|
||||
# LIBRETRANSLATE_URL=https://libretranslate.example.com
|
||||
# LIBRETRANSLATE_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# Web Push
|
||||
# =============================================================================
|
||||
|
||||
# Push notifications go through a hosted relay so self-hosters don't need
|
||||
# their own VAPID keys and Firebase project. Point this at your own relay to
|
||||
# avoid the default. Build-time variable.
|
||||
# Default: https://notifications.relay.bulwarkmail.org
|
||||
# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com
|
||||
|
||||
# =============================================================================
|
||||
# Demo Mode
|
||||
# =============================================================================
|
||||
|
||||
# Serve fixture data instead of talking to a mail server. Default: false.
|
||||
# DEMO_MODE=true
|
||||
|
||||
# =============================================================================
|
||||
# Stalwart Impersonation (advanced)
|
||||
# =============================================================================
|
||||
|
||||
# Lets a trusted platform mint a JWT that logs a user in without their
|
||||
# password, using a Stalwart master account. Intended for embedded
|
||||
# deployments where an outer platform already authenticated the user.
|
||||
#
|
||||
# SECURITY: this grants sign-in as any mailbox on the server. The endpoint
|
||||
# returns 404 unless all three required variables below are set, so leaving
|
||||
# them unset keeps the feature fully off. Treat the secret and the master
|
||||
# password as you would a root credential.
|
||||
#
|
||||
# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters
|
||||
# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com
|
||||
# BULWARK_STALWART_MASTER_PASSWORD= # required
|
||||
# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail"
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
@@ -274,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl,
|
||||
# pt, ro, ru, sk, tr, uk, zh
|
||||
# An unsupported value falls back to "en".
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
|
||||
@@ -118,3 +118,114 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
build-always:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=always-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests-always
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests-always/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests-always/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-always:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-always
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests-always
|
||||
pattern: digests-always-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
suffix=-always,onlatest=true
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests-always
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
+11
@@ -50,3 +50,14 @@ next-env.d.ts
|
||||
|
||||
# Sibling repos
|
||||
/repos/
|
||||
|
||||
# k8s deploy secret (create from deploy/k8s/secret.example.yaml)
|
||||
/deploy/k8s/secret.yaml
|
||||
|
||||
# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build)
|
||||
vnc/plugins/smime/node_modules/
|
||||
vnc/plugins/smime/dist/
|
||||
vnc/plugins/smime/smime-vnc.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.8 (2026-07-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Unified Mailbox**: Account-bounded Unified Mailbox with opt-in cross-account aggregation (#509)
|
||||
- **Unified Mailbox**: Search in the unified views
|
||||
- **Unified Mailbox**: Live unified/All-Mail counters for shared and group accounts
|
||||
- **Mail**: Message-list category tabs
|
||||
- **Mail**: Drag-and-drop reorder for all folders
|
||||
- **Mail**: Collapse quoted reply text behind a "..." toggle (#480)
|
||||
- **Mail**: Bulk Not-Spam action in the junk selection toolbar
|
||||
- **Mail**: Unread count badge on the favicon
|
||||
- **Mail**: Message spacing setting (auto/always/edge-to-edge)
|
||||
- **Mail**: Open external links in a new tab (safely)
|
||||
- **Mail**: Strip external `url()`/`@import` from `<style>` blocks in the sanitizer (#457)
|
||||
- **Composer**: Text color picker in the composer toolbar
|
||||
- **Composer**: Contact groups as single expandable recipient chips
|
||||
- **Composer**: Drag-to-reorder To/Cc/Bcc recipient chips (#593)
|
||||
- **Composer**: Auto-detect paragraph text direction by default
|
||||
- **Templates**: HTML template support
|
||||
- **Vacation**: HTML body support in the vacation responder
|
||||
- **Send**: 'Send now' action on the send-delay toast
|
||||
- **Accounts**: Remove a specific account from the switcher
|
||||
- **Settings**: "Refresh cached data" recovery action
|
||||
- **i18n**: Full Arabic (ar) translation with RTL support
|
||||
- **Login**: `LOGIN_SHOW_TOTP` and `LOGIN_SHOW_VERSION` config flags (#520)
|
||||
- **Docker**: `NEXT_PUBLIC_LOCALE_PREFIX` build argument
|
||||
- **Plugins**: `ui.rerenderFetchedEmails` method (#668)
|
||||
- **Plugins**: `onEmailsFetched` and `onSearchResults` hooks and `getSomeEmails` JMAP method
|
||||
- **Plugins**: `onRecipientChipsChange` hook
|
||||
- **Plugins**: `webauthn.getOrCreate` API method
|
||||
- **Plugins**: Download files generated by a plugin (with `ui:download-file` consent permission)
|
||||
- **Plugins**: Submit mail without moving to a mailbox and import-to-mailbox APIs
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Render the email body on DOM parse instead of iframe load (#635)
|
||||
- **Mail**: Keep sidebar tag counts in step with read/unread changes
|
||||
- **Mail**: Enable thread expansion in the focused list
|
||||
- **Mail**: Show the quote bar in email replies
|
||||
- **Mail**: Honor part-type fallback when quoting replies (#649)
|
||||
- **Mail**: Detect typing inside the quoted-HTML shadow island (#654)
|
||||
- **Mail**: Keep `target`/`rel` on links in plain-text message bodies and open signature links in a new tab
|
||||
- **Accounts**: Eliminate the full-screen flash when switching accounts (including cached accounts)
|
||||
- **Accounts**: Recognize canonicalized login usernames in the account-switch guard
|
||||
- **Auth**: Guard account switch against slot→token desync and basic-auth identity mismatches
|
||||
- **Auth**: End refresh loops on sign-out and back off failed retries
|
||||
- **OAuth**: Harden OIDC discovery (timeout, retry, serve-stale)
|
||||
- **JMAP**: Preserve POST across redirects in the Stalwart JMAP passthrough (#627)
|
||||
- **JMAP**: File the post-send message with a full `mailboxIds` replacement
|
||||
- **JMAP**: Generate the Message-ID client-side using the sender's domain
|
||||
- **Identity**: Sync the default sender identity per account (#507)
|
||||
- **Attachments**: Download/view attachments on cross-account All-Mail messages
|
||||
- **Shared folders**: Route batch actions to the owner account
|
||||
- **Templates**: Insert a mail template at the caret in replies instead of prepending (#539)
|
||||
- **Templates**: Keep the signature when inserting a template (#621)
|
||||
- **Templates**: Hide template buttons when templates are disabled
|
||||
- **Calendar**: Honor "Show time in month view" on mobile instead of forcing dots (#666)
|
||||
- **Calendar**: Classify self-organized imported events as editable
|
||||
- **Contacts**: Assign a UID to contact cards on creation (#644)
|
||||
- **Spam**: Stop HELO `spf=none` from downgrading a MAIL FROM `spf=pass` (#650)
|
||||
- **Drafts**: Label the close-dialog draft button with the generic Save
|
||||
- **RTL**: Flip JS-positioned popovers and anchor floating menus with logical start/end
|
||||
- **RTL**: Isolate Latin address text from RTL bidi reordering and force LTR identity options
|
||||
- **i18n**: Register Arabic messages in the client IntlProvider
|
||||
- **i18n**: Fix the Hebrew Drafts folder label
|
||||
- **i18n**: Add missing translation keys across 22 locales
|
||||
- **Deps**: Bump `dompurify` to 3.4.12 and `next-intl` to 4.13.3
|
||||
|
||||
## 1.7.7 (2026-07-09)
|
||||
|
||||
### Features
|
||||
|
||||
+79
-33
@@ -10,13 +10,13 @@
|
||||
|
||||
# Contributing to Bulwark Webmail
|
||||
|
||||
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
|
||||
We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them.
|
||||
|
||||
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
|
||||
If that sounds like your kind of project, we'd love the help.
|
||||
|
||||
## Join the Community
|
||||
## Join the community
|
||||
|
||||
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
|
||||
You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this.
|
||||
|
||||
- **Get support** - real-time help with development hurdles
|
||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
||||
@@ -26,9 +26,9 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
## Getting started
|
||||
|
||||
### Development Setup
|
||||
### Development setup
|
||||
|
||||
1. **Fork and clone** the repository:
|
||||
|
||||
@@ -46,16 +46,22 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
|
||||
3. **Set up environment**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your JMAP server URL
|
||||
cp .env.dev.example .env.local
|
||||
```
|
||||
|
||||
This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can
|
||||
develop without a mail server. Log in with any username and password. To work
|
||||
against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`.
|
||||
|
||||
4. **Start development server**:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
Then open http://localhost:3000.
|
||||
|
||||
### Code quality
|
||||
|
||||
Before submitting a pull request, ensure your code passes all checks:
|
||||
|
||||
@@ -72,7 +78,20 @@ npm run lint:fix
|
||||
|
||||
These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Code Style Guidelines
|
||||
### Testing
|
||||
|
||||
| Suite | Command | What it covers |
|
||||
| ---------------- | -------------------------- | ------------------------------------------------------------------ |
|
||||
| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code |
|
||||
| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English |
|
||||
| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker |
|
||||
| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` |
|
||||
|
||||
Run a single unit test file with `npx vitest run lib/__tests__/<name>.test.ts`, or `npx vitest` to watch.
|
||||
|
||||
The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there.
|
||||
|
||||
## Code style guidelines
|
||||
|
||||
### TypeScript
|
||||
|
||||
@@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
- Avoid `any` types when possible
|
||||
- Use meaningful variable and function names
|
||||
|
||||
### React Components
|
||||
### React components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components focused and single-purpose
|
||||
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
|
||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh).
|
||||
|
||||
Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities.
|
||||
|
||||
### Rules
|
||||
|
||||
@@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
### Adding a new locale
|
||||
|
||||
### Before Submitting
|
||||
Registering a new locale takes edits in four places:
|
||||
|
||||
1. `locales/<code>/common.json` - copy `locales/en/common.json` and translate
|
||||
2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES`
|
||||
3. `i18n/request.ts` - add a `case` to the static-import switch
|
||||
4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx`
|
||||
|
||||
For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`.
|
||||
|
||||
Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English.
|
||||
|
||||
## Pull request process
|
||||
|
||||
### Before submitting
|
||||
|
||||
1. **Create a feature branch**:
|
||||
|
||||
@@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
||||
|
||||
2. **Make your changes** following the code style guidelines
|
||||
|
||||
3. **Test your changes** thoroughly
|
||||
3. **Test your changes** thoroughly, and add unit tests for new logic
|
||||
|
||||
4. **Update translations** if you added user-facing text
|
||||
|
||||
5. **Run all checks**:
|
||||
```bash
|
||||
npm run typecheck && npm run lint
|
||||
npm run typecheck && npm run lint && npx vitest run
|
||||
```
|
||||
|
||||
### Submitting
|
||||
@@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
||||
- Screenshots for UI changes
|
||||
- Reference to any related issues
|
||||
|
||||
### Commit Message Convention
|
||||
### Commit message convention
|
||||
|
||||
Follow the conventional commits format:
|
||||
|
||||
@@ -177,25 +211,37 @@ fix: resolve attachment download issue
|
||||
docs: update README with keyboard shortcuts
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Project structure
|
||||
|
||||
```
|
||||
webmail/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ └── [locale]/ # Locale-aware routing
|
||||
├── components/ # React components
|
||||
│ ├── email/ # Email-related components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── settings/ # Settings components
|
||||
│ └── ui/ # Reusable UI components
|
||||
├── contexts/ # React contexts
|
||||
├── hooks/ # Custom React hooks
|
||||
├── lib/ # Utilities and libraries
|
||||
│ └── jmap/ # JMAP client implementation
|
||||
├── locales/ # Translation files
|
||||
│ ├── en/ # English translations
|
||||
│ └── fr/ # French translations
|
||||
└── stores/ # Zustand state stores
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
|
||||
│ ├── (main)/admin/ # Admin dashboard
|
||||
│ ├── (main)/setup/ # First-launch setup wizard
|
||||
│ ├── (sandbox)/ # Isolated plugin sandbox routes
|
||||
│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
|
||||
├── components/ # React components
|
||||
│ ├── email/ # Email list, viewer, composer
|
||||
│ ├── calendar/ contacts/ files/ filters/ templates/
|
||||
│ ├── layout/ # Sidebar, shell, navigation
|
||||
│ ├── settings/ # Settings panels
|
||||
│ ├── plugins/ # Plugin host UI
|
||||
│ └── ui/ # Reusable primitives
|
||||
├── contexts/ # React contexts
|
||||
├── hooks/ # Custom React hooks
|
||||
├── i18n/ # next-intl routing, locale detection, RTL direction
|
||||
├── lib/ # Utilities and libraries
|
||||
│ ├── jmap/ # JMAP client implementation
|
||||
│ ├── stalwart/ # Stalwart-specific admin/API helpers
|
||||
│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
|
||||
│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening
|
||||
│ └── __tests__/ # Vitest unit tests
|
||||
├── locales/ # Translation files, one directory per locale
|
||||
├── stores/ # Zustand state stores
|
||||
├── public/ # Static assets and branding
|
||||
├── e2e/ # Playwright smoke tests (against `npm run dev`)
|
||||
└── integration/ # Dockerized Stalwart + Playwright suite
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Optional: avoid next-intl rewrite loops when served under a subpath.
|
||||
# Baked in at build time.
|
||||
ARG NEXT_PUBLIC_LOCALE_PREFIX=
|
||||
ENV NEXT_PUBLIC_LOCALE_PREFIX=$NEXT_PUBLIC_LOCALE_PREFIX
|
||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||
# Accept-Language header does not match any supported locale. Baked in at
|
||||
# build time because next-intl wires it into client-side routing too.
|
||||
|
||||
+111
-107
@@ -2,145 +2,149 @@
|
||||
|
||||
## Mail
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
||||
- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
|
||||
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||
- Scheduled send and configurable send delay
|
||||
- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables
|
||||
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
|
||||
- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account.
|
||||
- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from.
|
||||
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
|
||||
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
|
||||
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
|
||||
- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it.
|
||||
- Scheduled send, plus a configurable delay before anything leaves the outbox
|
||||
- Read receipts (MDN, RFC 8098)
|
||||
- Editable, layout-preserving quote island when replying
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- Configurable signature position (above or below quoted text) per identity
|
||||
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
|
||||
- `.eml` file import via folder right-click menu
|
||||
- Quoted text lands in an editable island that keeps the original layout
|
||||
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Multi-select for batch archive, delete, move, and tag
|
||||
- Archive directly, by year, or by month
|
||||
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
||||
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||
- Star or unstar, with a configurable mark-as-read delay
|
||||
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||
- Plain-text composer mode and Reply-To
|
||||
- The signature sits above or below the quoted text, per identity
|
||||
- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it.
|
||||
- Import `.eml` files from the folder right-click menu
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
- Browser history sync for back/forward navigation
|
||||
- Folders take an icon, nest, and show counts in the sidebar
|
||||
- Print from the viewer
|
||||
- Browser back and forward move through mail history
|
||||
|
||||
## Calendar
|
||||
|
||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||
- iCalendar import with preview, bulk create, and UID deduplication
|
||||
- iCal / webcal subscriptions with editing and batch import
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
|
||||
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
|
||||
- Recurring events edit and delete by scope: this occurrence, this and following, or all
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
|
||||
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
|
||||
- iCalendar import previews first, then bulk-creates, deduplicating on UID
|
||||
- iCal / webcal subscriptions, editable, with batch import
|
||||
- A birthday calendar generated from your contacts
|
||||
- Virtual locations (video-conference URLs) are first-class event fields
|
||||
- Tasks with due dates, priority, and completion status
|
||||
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
|
||||
- Week numbers, hover preview, notifications with a sound picker
|
||||
- JMAP push keeps everything in sync
|
||||
|
||||
## Contacts
|
||||
|
||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||
- Multiple address books with drag-and-drop between books
|
||||
- Contact groups with member management
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Trusted senders stored in a dedicated JMAP address book
|
||||
- Autocomplete in the composer (To / Cc / Bcc)
|
||||
- JMAP sync (RFC 9553 / 9610), falling back to local storage
|
||||
- Several address books, with drag-and-drop between them
|
||||
- Groups with member management
|
||||
- vCard import/export (RFC 6350) that flags duplicates
|
||||
- Trusted senders live in their own JMAP address book
|
||||
- Autocomplete on To, Cc, and Bcc
|
||||
|
||||
## Filters & Templates
|
||||
## Filters & templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Server-side filters as JMAP Sieve Scripts (RFC 9661)
|
||||
- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard
|
||||
- Rules written in other clients survive the round-trip
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
- A vacation responder you can schedule to a date range
|
||||
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
|
||||
- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load.
|
||||
- Streamed WebDAV PUT upload, whole folders included, with progress
|
||||
- Upload limits follow the server's own configuration
|
||||
- Grid or list, sorted by name, size, or date
|
||||
- Preview images, text, audio, and video
|
||||
- Cut, copy, paste, duplicate; favorites; recent files
|
||||
- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me".
|
||||
|
||||
## Security & Privacy
|
||||
## Security & privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- External content stays blocked until you say otherwise, and trusted senders are remembered
|
||||
- HTML sanitized through DOMPurify
|
||||
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
|
||||
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||
- Password and 2FA management through the Stalwart admin API
|
||||
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
|
||||
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
|
||||
- Plugins are scanned for dangerous patterns and need admin approval
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
|
||||
## Interface
|
||||
|
||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Split three-pane, focused list, or bottom reading pane, columns resizable
|
||||
- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background.
|
||||
- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants.
|
||||
- Layouts for desktop, tablet, and mobile
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
- Interactive guided tour for new users
|
||||
- Right-click context menus, toast notifications with undo
|
||||
- Customizable toolbar position, favicon, and login branding
|
||||
- Pinnable sidebar apps with drag-and-drop reordering
|
||||
- Encrypted settings sync across devices
|
||||
- Drag and drop to organize mail and assign tags
|
||||
- A guided tour for first-time users
|
||||
- Right-click menus, and toasts that offer an undo
|
||||
- Toolbar position, favicon, and login branding are configurable
|
||||
- Sidebar apps pin and reorder by drag
|
||||
- Settings sync between devices, encrypted
|
||||
- Storage quota display
|
||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||
- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
|
||||
|
||||
## Internationalization
|
||||
|
||||
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically
|
||||
- The browser's `Accept-Language` picks the first language, and the choice persists per user
|
||||
- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix
|
||||
|
||||
## Identity & Multi-Account
|
||||
## Identity & multi-account
|
||||
|
||||
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
|
||||
- Account switcher with connection status and default account selection
|
||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||
- Configurable signature position (above or below quoted text)
|
||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||
- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit.
|
||||
- An account switcher showing connection status, and a default account
|
||||
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
|
||||
- Signature above or below the quoted text
|
||||
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
|
||||
- Shared folders across accounts
|
||||
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step.
|
||||
- Several JMAP servers per deployment, optionally auto-picked by email domain
|
||||
- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
|
||||
|
||||
## Admin & Extensibility
|
||||
## Admin & extensibility
|
||||
|
||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||
- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
|
||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||
- Bundled plugins including Jitsi Meet calendar integration
|
||||
- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched.
|
||||
- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
|
||||
- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle.
|
||||
- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps.
|
||||
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
|
||||
- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
|
||||
- Themes upload as ZIP bundles, and admins can enforce one
|
||||
- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard.
|
||||
- Bundled plugins, including Jitsi Meet for the calendar
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – version, platform, bucketed account counts, feature toggles only
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||
- Demo mode with fixture data – no mail server required
|
||||
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
|
||||
- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed
|
||||
- Structured logging (`text` or `json`) with per-category levels
|
||||
- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
|
||||
- Docker images on GHCR, for release (`main`) and development (`dev`)
|
||||
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
|
||||
- Demo mode runs on fixture data, no mail server required
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
# Bulwark Webmail
|
||||
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
## Installer
|
||||
|
||||
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
|
||||
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
|
||||
</picture>
|
||||
Since **1.6.4**, a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||
|
||||
Point a browser at the running container and the wizard guides you through:
|
||||
|
||||
@@ -70,27 +65,27 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||
<td><sub><b>Light mode</b> – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
|
||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
## What Bulwark includes
|
||||
|
||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:
|
||||
|
||||
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four.
|
||||
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -104,9 +99,9 @@ Or with Docker Compose:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
|
||||
On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration).
|
||||
|
||||
### From Source
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bulwarkmail/webmail.git
|
||||
@@ -119,16 +114,20 @@ npm run build && npm start
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server with a mock JMAP server
|
||||
cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed
|
||||
|
||||
npm run dev # Dev server
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npx vitest run # Unit tests
|
||||
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
|
||||
Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI.
|
||||
|
||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||
Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`:
|
||||
|
||||
```env
|
||||
# Optional – overrides whatever the wizard writes
|
||||
@@ -151,13 +150,28 @@ PORT=3000
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_ONLY=true # hide the username/password form entirely
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||
OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint
|
||||
OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses
|
||||
```
|
||||
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Anonymous telemetry</summary>
|
||||
|
||||
```env
|
||||
BULWARK_TELEMETRY=on # opt-in; off by default
|
||||
TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume
|
||||
```
|
||||
|
||||
Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Default UI locale</summary>
|
||||
|
||||
The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`):
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE=de
|
||||
```
|
||||
|
||||
Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`.
|
||||
|
||||
Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own:
|
||||
|
||||
```bash
|
||||
docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail .
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Subpath / reverse proxy mount</summary>
|
||||
|
||||
@@ -271,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b
|
||||
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||
```
|
||||
|
||||
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
|
||||
Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
|
||||
|
||||
</details>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ------------- | ----------------------- |
|
||||
| `j` / `k` | Navigate between emails |
|
||||
| `Enter` / `o` | Open email |
|
||||
| `Esc` | Close / deselect |
|
||||
| `c` | Compose |
|
||||
| `r` / `R` | Reply / Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Star |
|
||||
| `e` | Archive |
|
||||
| `#` | Delete |
|
||||
| `/` | Search |
|
||||
| `?` | Show all shortcuts |
|
||||
| Key | Action |
|
||||
| -------------------- | ----------------------- |
|
||||
| `j` `↓` / `k` `↑` | Navigate between emails |
|
||||
| `Enter` / `o` | Open email |
|
||||
| `Esc` | Close / deselect |
|
||||
| `x` | Expand / collapse thread |
|
||||
| `c` | Compose |
|
||||
| `r` / `R` `a` | Reply / Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Star |
|
||||
| `e` | Archive |
|
||||
| `#` / `Del` | Delete |
|
||||
| `u` / `Shift`+`I` | Mark unread / read |
|
||||
| `!` | Toggle spam |
|
||||
| `Ctrl`+`A` | Select all |
|
||||
| `Shift`+`G` | Refresh |
|
||||
| `/` | Search |
|
||||
| `?` | Show all shortcuts |
|
||||
|
||||
## Tech Stack
|
||||
In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| | |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router |
|
||||
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 |
|
||||
| **Language** | TypeScript |
|
||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
||||
| **Protocol** | Custom JMAP client (RFC 8620) |
|
||||
| **Editor** | [Tiptap](https://tiptap.dev/) |
|
||||
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
||||
| **Icons** | [Lucide React](https://lucide.dev/) |
|
||||
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
|
||||
|
||||
## Why Stalwart?
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# VNCmail+ — setup & deploy runbook
|
||||
|
||||
VNCmail+ is VNC's fork of [Bulwark](https://github.com/bulwarkmail/webmail), a
|
||||
Next.js (App Router) JMAP webmail client for **Stalwart**. Stalwart is the source
|
||||
of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
|
||||
(microk8s)** at `vncmail.sandbox.vnc.de` — see **[deploy/k8s/](deploy/k8s/README.md)**.
|
||||
|
||||
> **License:** AGPL-3.0. Serving a modified VNCmail+ to users over the network
|
||||
> obligates VNC to offer those users the corresponding source. Keeping this fork
|
||||
> public (with a "Source" link in the imprint/UI) satisfies that. Loop in legal
|
||||
> before a public/customer-facing launch if a closed fork is ever desired.
|
||||
|
||||
## Architecture — why a container, not Vercel
|
||||
|
||||
- Bulwark is a **stateful, long-lived server**: it persists settings-sync, admin
|
||||
config/state, and telemetry to a **local data directory** (`/app/data/*`).
|
||||
- **Vercel serverless was tried and dropped** — its filesystem is read-only
|
||||
except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`).
|
||||
You cannot point its data dirs at a remote host either (they're POSIX paths,
|
||||
not URLs). Bulwark's native model is a container + persistent volumes.
|
||||
- So VNCmail+ runs as a Docker image (`ghcr.io/brvncde-dotcom/vncmail-plus-*`)
|
||||
with **4 persistent volumes**, exactly like the existing `bulwark.sandbox.vnc.de`.
|
||||
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
|
||||
server to Stalwart, **no browser CORS**. Config is **runtime-read**.
|
||||
|
||||
## Branches (dev-first)
|
||||
|
||||
| Branch | Role |
|
||||
|--------|------|
|
||||
| `main` | **Production** — CI builds `…/vncmail-plus-beta`. Only updated by an explicit promote. |
|
||||
| `dev` | Integration + QA — CI builds `…/vncmail-plus-dev` on push. Default working branch. |
|
||||
| `vnc/*`| Feature branches for UI work (branch off `dev`, PR into `dev`). |
|
||||
|
||||
All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`).
|
||||
|
||||
## Deploy (Kubernetes / microk8s)
|
||||
|
||||
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
|
||||
|
||||
1. CI builds the image on push to `dev`/`main` → `ghcr.io/brvncde-dotcom/vncmail-plus-dev` (`.github/workflows/docker-publish.yml`).
|
||||
2. `kubectl apply` the manifests in `deploy/k8s/` (namespace, 4 PVCs, deployment, service, ingress) + a `secret.yaml` (from `secret.example.yaml`) + a `ghcr-pull` image-pull secret.
|
||||
3. Point `vncmail.sandbox.vnc.de` DNS at the ingress; cert-manager issues TLS.
|
||||
|
||||
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
|
||||
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
|
||||
|
||||
## Deploy workflow (dev-first — ALWAYS)
|
||||
|
||||
Same flow as every other VNC/SRC repo:
|
||||
|
||||
1. Work on `dev` (or `vnc/*` → PR into `dev`). Push to `dev` → CI builds the `-dev` image → `kubectl -n vncmail rollout restart deploy/vncmail-plus` to pull it. QA at `vncmail.sandbox.vnc.de`.
|
||||
2. **Promote to production only on explicit go-live** — merge `dev` → `main`:
|
||||
```bash
|
||||
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
|
||||
git checkout main && git merge --ff-only dev
|
||||
git push origin main # CI builds the production image
|
||||
git checkout dev
|
||||
```
|
||||
Then roll the production deployment to the new image (pin its digest — see deploy/k8s/README.md).
|
||||
Never push straight to `main`. Never let a dev→main merge silently revert prod.
|
||||
|
||||
## Syncing upstream (Bulwark releases)
|
||||
|
||||
Bring upstream into `dev` (NOT main), integrate + QA on the dev image, then promote as above:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout dev && git merge upstream/main # resolve conflicts via vnc/VNC-CHANGES.md; QA on preview
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
Basic auth via Stalwart is the default — users sign in with their
|
||||
`@sandbox.vnc.de` address + password; VNCmail+ authenticates them over JMAP. No
|
||||
extra config. (SSO via vncdirectory/OIDC is a later option — see
|
||||
`vnc/vercel.env.template`.)
|
||||
@@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -400,8 +400,8 @@ export default function ContactsPage() {
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _created; void _updated;
|
||||
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _uid; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
@@ -513,23 +513,31 @@ export default function ContactsPage() {
|
||||
}, [router]);
|
||||
|
||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||
// Format each member as "Name <email>" so the composer keeps the display
|
||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
||||
// email, case-insensitively; members without an email are skipped.
|
||||
// Hand the composer a single group chip (RFC 5322 group syntax survives
|
||||
// the string hand-off) instead of one entry per member - the chip expands
|
||||
// into the members when the message is sent. Dedupe by email,
|
||||
// case-insensitively; members without an email are skipped.
|
||||
const seen = new Set<string>();
|
||||
const recipients: string[] = [];
|
||||
const members: Array<{ name?: string; email: string }> = [];
|
||||
for (const member of getGroupMembers(groupId)) {
|
||||
const email = getContactPrimaryEmail(member).trim();
|
||||
const key = email.toLowerCase();
|
||||
if (!email || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
||||
const name = getContactDisplayName(member);
|
||||
members.push({ name: name && name !== email ? name : undefined, email });
|
||||
}
|
||||
if (recipients.length === 0) {
|
||||
if (members.length === 0) {
|
||||
toast.error(t("groups.no_member_emails"));
|
||||
return;
|
||||
}
|
||||
openComposeInApp(recipients, field);
|
||||
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
|
||||
const chip: Recipient = {
|
||||
name: (group && getContactDisplayName(group)) || "Group",
|
||||
email: "",
|
||||
group: { members },
|
||||
};
|
||||
openComposeInApp([formatRecipientEntry(chip)], field);
|
||||
}, [getGroupMembers, t, openComposeInApp]);
|
||||
|
||||
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
<ProInterfaceRedirect />
|
||||
<ImpersonationReconciler />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||
@@ -133,8 +134,12 @@ export default function LoginPage() {
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
||||
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
||||
// fall back to the globally configured login logo.
|
||||
const effLoginLogo = resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', loginLogoLightUrl, loginLogoDarkUrl);
|
||||
|
||||
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||
@@ -740,7 +745,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
src={withBasePath(effLoginLogo)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -822,7 +827,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VersionBadge />
|
||||
{loginShowVersion && <VersionBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -890,7 +895,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||
<img
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
src={withBasePath(effLoginLogo)}
|
||||
alt={appName}
|
||||
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||
style={loginLogoStyle}
|
||||
@@ -1154,8 +1159,12 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2FA toggle / field */}
|
||||
{/* 2FA toggle / field. The manual toggle can be hidden via
|
||||
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
|
||||
server has no per-account TOTP (auth delegated to an
|
||||
external directory); server-required TOTP still shows. */}
|
||||
{!showTotpField ? (
|
||||
loginShowTotp ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1167,6 +1176,7 @@ export default function LoginPage() {
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{t("totp_toggle")}
|
||||
</button>
|
||||
) : null
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||
@@ -1361,7 +1371,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VersionBadge />
|
||||
{loginShowVersion && <VersionBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+339
-97
@@ -5,13 +5,14 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { MessageListTabs } from "@/components/email/message-list-tabs";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -33,6 +34,7 @@ import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
@@ -60,7 +62,9 @@ import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -70,11 +74,12 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
@@ -110,7 +115,7 @@ export default function Home() {
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -118,6 +123,7 @@ export default function Home() {
|
||||
const initialMailLoadClientRef = useRef<object | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
@@ -279,6 +285,7 @@ export default function Home() {
|
||||
toggleStar,
|
||||
setEmailKeywordsLocal,
|
||||
moveToMailbox,
|
||||
moveToMailboxCrossAware,
|
||||
moveThreadToMailbox,
|
||||
searchEmails,
|
||||
searchQuery,
|
||||
@@ -356,10 +363,7 @@ export default function Home() {
|
||||
useProMultiAccountMailboxes();
|
||||
|
||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
||||
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
|
||||
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
|
||||
|
||||
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
|
||||
// they require Unified Mailbox to be enabled, plus the admin gate and the
|
||||
@@ -377,18 +381,36 @@ export default function Home() {
|
||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
|
||||
const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled'));
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||
[accounts],
|
||||
);
|
||||
// Cross-account is "active" when the user opted in, the admin allows it, and
|
||||
// more than one account is connected. Drives the sidebar header label: the
|
||||
// old "All accounts" when spanning accounts, else "Unified Mailbox".
|
||||
const crossAccountActive =
|
||||
unifiedCrossAccount &&
|
||||
unifiedCrossAccountGate &&
|
||||
accounts.filter((a) => a.isConnected).length > 1;
|
||||
|
||||
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||
// effects and one-shot actions in this page. Reads the includeGroup
|
||||
// setting at call time so the latest toggle value is always honored.
|
||||
// effects and one-shot actions in this page. Reads the settings at call time
|
||||
// so the latest toggle values are always honored. When the cross-account
|
||||
// sub-option is off, the unified mailbox stays within the active account
|
||||
// boundary (its own + shared folders); when on, it spans every login account.
|
||||
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
|
||||
// Cross-account scope requires both the per-user opt-in and the admin
|
||||
// capability gate; otherwise stay within the active account boundary.
|
||||
const crossAccount = useSettingsStore.getState().unifiedCrossAccount
|
||||
&& usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled');
|
||||
return buildUnifiedAccountClients({
|
||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
||||
scopeToClientAccountId: crossAccount
|
||||
? undefined
|
||||
: (useAccountStore.getState().activeAccountId ?? undefined),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -765,15 +787,22 @@ export default function Home() {
|
||||
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded || !showComposer) return;
|
||||
const replyTo = selectedEmail ? {
|
||||
// pendingDraft.replyTo, when set, was built by the opener (e.g.
|
||||
// handleForwardAsAttachment) with intent that must survive the hop into
|
||||
// the Pro tab - mirrors the same precedence the non-embedded render path
|
||||
// uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo
|
||||
// : ...}`). Building fresh from selectedEmail unconditionally here would
|
||||
// silently drop that intent (e.g. the synthetic message/rfc822
|
||||
// attachment "Forward as attachment" stages), falling back to a normal
|
||||
// quoted forward instead.
|
||||
const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
...getQuoteBodies(selectedEmail),
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
@@ -782,7 +811,7 @@ export default function Home() {
|
||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||
quoteHeaderText: composerQuoteHeader?.text,
|
||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||
} : undefined;
|
||||
} : undefined);
|
||||
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||
@@ -965,11 +994,16 @@ export default function Home() {
|
||||
|
||||
await refreshScheduledMetadata(client);
|
||||
|
||||
// Fetch emails for the selected mailbox after scheduled metadata is available.
|
||||
// Fetch emails for the selected mailbox after scheduled metadata is
|
||||
// available. If the list is already populated (an account switch
|
||||
// restored a cached snapshot, or login prefetched it), refresh in the
|
||||
// background so the visible mail doesn't flash a loading overlay; only
|
||||
// a genuine empty first load shows the skeleton.
|
||||
const background = state.emails.length > 0;
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
await fetchEmails(client, selectedMailboxId, { background });
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
await fetchEmails(client, undefined, { background });
|
||||
}
|
||||
|
||||
fetchTagCounts(client);
|
||||
@@ -986,29 +1020,52 @@ export default function Home() {
|
||||
};
|
||||
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||
|
||||
// Push notifications: set up once per client and tear down when the client
|
||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
||||
// above so it still runs when data was prefetched at login time.
|
||||
// Push notifications: set up once per CONNECTED client and tear down when the
|
||||
// clients go away (logout or account switch). Kept separate from the fetch
|
||||
// effect above so it still runs when data was prefetched at login time.
|
||||
//
|
||||
// We bind every connected login, not just the active one: background accounts
|
||||
// must drive the unified-section counters too. The active client keeps the
|
||||
// full handler (current list / scheduled / calendar / filters); background
|
||||
// logins only re-project the unified counts by rebuilding the unified scope
|
||||
// (which refreshes every account's cached mailbox list), since their changes
|
||||
// never touch the active `mailboxes`. (#281 background push)
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
try {
|
||||
client.onStateChange((change) => handleStateChange(change, client));
|
||||
const pushEnabled = client.setupPushNotifications();
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('push', '[Push] Push notifications not available on this server');
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
for (const [accId, c] of clients) {
|
||||
try {
|
||||
if (accId === activeAccountId) {
|
||||
c.onStateChange((change) => handleStateChange(change, c));
|
||||
} else {
|
||||
c.onStateChange(() => {
|
||||
buildPopulatedUnifiedAccounts()
|
||||
.then((built) => {
|
||||
refreshCrossCounts(built);
|
||||
refreshUnifiedCounts(built);
|
||||
})
|
||||
.catch(() => { /* per-account fetch failures surface elsewhere */ });
|
||||
});
|
||||
}
|
||||
c.setupPushNotifications();
|
||||
cleanups.push(() => c.closePushNotifications());
|
||||
} catch (error) {
|
||||
debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error);
|
||||
}
|
||||
} catch (error) {
|
||||
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||
}
|
||||
|
||||
if (cleanups.length > 0) {
|
||||
setPushConnected(true);
|
||||
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
client.closePushNotifications();
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, [isAuthenticated, client, handleStateChange, setPushConnected]);
|
||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||
|
||||
// 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
|
||||
@@ -1025,7 +1082,7 @@ export default function Home() {
|
||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||
refreshUnifiedCounts(built);
|
||||
});
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -1179,6 +1236,14 @@ export default function Home() {
|
||||
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
|
||||
setShowComposer(false);
|
||||
if (result.filingError) {
|
||||
// The mail went out, but a post-send step (filing to Sent /
|
||||
// removing the old draft) was rejected - warn instead of staying
|
||||
// silent, so a stale draft row is not mistaken for a failed send
|
||||
// and re-sent (#592).
|
||||
const toastInstance = (await import('sonner')).toast;
|
||||
toastInstance.warning(t('email_composer.send_filing_warning'));
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
@@ -1349,15 +1414,24 @@ export default function Home() {
|
||||
const bodyText = draft.bodyValues
|
||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||
: '';
|
||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||
const draftHtmlPart = draft.htmlBody?.[0];
|
||||
const htmlBody = draftHtmlPart?.partId
|
||||
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
|
||||
&& draft.bodyValues?.[draftHtmlPart.partId]
|
||||
? draft.bodyValues[draftHtmlPart.partId].value
|
||||
: undefined;
|
||||
|
||||
// Try to find the identity that matches the draft's from address to preserve it
|
||||
const draftFromEmail = draft.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find(id => id.email === draftFromEmail)
|
||||
: null;
|
||||
// Restore the identity the draft was composed with. Match the saved From
|
||||
// (name + address) against the same list the composer renders — the flat
|
||||
// cross-account list when multi-account is on — so a draft from a non-active
|
||||
// account, or one of two identities sharing an address, is restored rather
|
||||
// than reset to the default.
|
||||
const composerIdentities = multiAccountIdentities.enabled
|
||||
? multiAccountIdentities.allIdentities
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityId(composerIdentities, draft.from?.[0]);
|
||||
|
||||
// Increment session ID to force the composer to remount with fresh state,
|
||||
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||
@@ -1370,7 +1444,7 @@ export default function Home() {
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (draft.cc?.length || 0) > 0,
|
||||
showBcc: (draft.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
selectedIdentityId: matchedIdentityId,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: draft.id,
|
||||
@@ -1390,6 +1464,27 @@ export default function Home() {
|
||||
|
||||
toast.success(t('email_viewer.scheduled_send_created'), {
|
||||
duration: undoDurationMs,
|
||||
secondaryAction: (pending.emailId && pending.identityId)
|
||||
? {
|
||||
label: t('email_viewer.send_now'),
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await client.rescheduleEmailSubmission(
|
||||
pending.submissionId,
|
||||
pending.emailId!,
|
||||
pending.identityId!,
|
||||
new Date(Date.now() + 1000).toISOString(),
|
||||
);
|
||||
clearPendingUndoSend();
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to send now:', error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
action: {
|
||||
label: t('email_viewer.undo_send'),
|
||||
onClick: () => {
|
||||
@@ -1455,6 +1550,77 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
// Forward the original message as a message/rfc822 attachment instead of
|
||||
// inline-quoted text - e.g. for reporting spam to an upstream gateway
|
||||
// that expects the raw original as an attachment, or preserving exact
|
||||
// formatting/headers the recipient needs to see untouched. Reuses the
|
||||
// same attachment-carry-forward mechanism native Forward already uses
|
||||
// for a forwarded message's own attachments (see the `attachments`
|
||||
// useState initializer in email-composer.tsx) - we just add one more
|
||||
// synthetic entry representing the whole original message, referenced
|
||||
// by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are
|
||||
// account-scoped, not per-email). Skips prepareComposerQuoteHeader
|
||||
// entirely, so the body starts blank instead of quoting the original.
|
||||
// Takes an explicit `email` (defaulting to selectedEmail), same pattern
|
||||
// handleDelete uses just below, rather than always reading selectedEmail
|
||||
// from this closure - callers that just called selectEmail(email) and
|
||||
// invoke this synchronously in the same tick would otherwise see the
|
||||
// PRE-update value (the Zustand store updates immediately, but this
|
||||
// render's selectedEmail closure doesn't until the next render),
|
||||
// forwarding the previously selected message or no-op'ing on an
|
||||
// unselected row. See the list context-menu wiring below.
|
||||
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
|
||||
if (!email) return;
|
||||
// Same filename options "Export as .eml" uses (see emailFilenameOptions
|
||||
// in email-viewer.tsx), so the two actions produce consistent filenames
|
||||
// for the same message rather than the synthetic attachment silently
|
||||
// ignoring the user's configured naming template.
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
} = useSettingsStore.getState();
|
||||
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
});
|
||||
if (!payload) return;
|
||||
|
||||
const ok = await emailHooks.onBeforeForward.intercept({
|
||||
originalEmailId: email.id,
|
||||
originalEmail: emailToReadView(email),
|
||||
mode: 'forward' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
startFreshComposerSession();
|
||||
setPendingDraft({
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
subject: payload.subject,
|
||||
body: "",
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: "",
|
||||
mode: "forward",
|
||||
draftId: null,
|
||||
replyTo: {
|
||||
subject: email.subject,
|
||||
attachments: [payload.attachment],
|
||||
},
|
||||
});
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToDelete) return;
|
||||
|
||||
@@ -1669,7 +1835,7 @@ export default function Home() {
|
||||
keywords['$pinned'] = true;
|
||||
}
|
||||
|
||||
// Same unified-view routing as color tags: write to the email's own
|
||||
// Same unified-view routing as tags: write to the email's own
|
||||
// account via the login it is reachable through. (#281)
|
||||
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||
@@ -1693,31 +1859,35 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
const handleSetTag = async (emailId: string, tagId: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Remove any existing label/color tags
|
||||
// Remove any existing tag keywords
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
if (color === null) {
|
||||
// Remove all label/color tags
|
||||
if (tagId === null) {
|
||||
// Remove all tag keywords
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const jmapKey = `$label:${color}`;
|
||||
if (keywords[jmapKey]) {
|
||||
// Toggle off if already active
|
||||
keywords[jmapKey] = false;
|
||||
// Both prefixes name the same tag when read, so taking one off has to
|
||||
// clear whichever spellings are actually set.
|
||||
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||
.filter(key => keywords[key]);
|
||||
if (activeKeys.length > 0) {
|
||||
activeKeys.forEach(key => {
|
||||
keywords[key] = false;
|
||||
});
|
||||
} else {
|
||||
// Add the tag without disturbing others
|
||||
keywords[jmapKey] = true;
|
||||
keywords[KEYWORD_PREFIX + tagId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1743,7 +1913,7 @@ export default function Home() {
|
||||
// Refresh tag counts
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to set color tag:", error);
|
||||
console.error("Failed to set tag:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1823,7 +1993,18 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
// Keep an active search across the switch and re-run it in this view
|
||||
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, searchQuery);
|
||||
}
|
||||
} else {
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
}
|
||||
refreshUnifiedCounts(populated);
|
||||
return;
|
||||
}
|
||||
@@ -1845,7 +2026,18 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchCrossViewAction(populated, view);
|
||||
// Keep an active search across the switch and re-run it in this view
|
||||
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, searchQuery);
|
||||
}
|
||||
} else {
|
||||
await fetchCrossViewAction(populated, view);
|
||||
}
|
||||
refreshCrossCounts(populated);
|
||||
return;
|
||||
}
|
||||
@@ -2191,13 +2383,16 @@ export default function Home() {
|
||||
setSearchQuery("");
|
||||
clearSearchFilters();
|
||||
if (!client) return;
|
||||
// In unified view the active "mailbox" is a virtual role, so refresh via
|
||||
// the unified fan-out instead of fetchEmails.
|
||||
// In unified view the active "mailbox" is a virtual role or cross view, so
|
||||
// refresh via the unified fan-out instead of fetchEmails.
|
||||
if (isUnifiedView) {
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
const role = useEmailStore.getState().unifiedRole;
|
||||
const cross = useEmailStore.getState().crossView;
|
||||
if (role) {
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
} else if (cross) {
|
||||
await fetchCrossViewAction(populated, cross);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2229,47 +2424,82 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Blobs are scoped per JMAP account. In the unified/All-Mail view the open
|
||||
// message may belong to another login (route to its client) or to a delegated
|
||||
// shared account (same client, but the owner's accountId in the download URL).
|
||||
// Resolve both from the email's source so attachments on cross-account
|
||||
// messages can be viewed/downloaded instead of 404ing against the active
|
||||
// account.
|
||||
const resolveBlobSource = useCallback((email: typeof selectedEmail) => {
|
||||
const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
const blobClient = clientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
return { blobClient, accountId, clientAccountId };
|
||||
}, [isUnifiedView, client]);
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
||||
if (!client) return;
|
||||
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
|
||||
if (!blobClient) return;
|
||||
|
||||
try {
|
||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||
|
||||
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||
setPreviewAttachment({ blobId, name, type });
|
||||
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
|
||||
return;
|
||||
}
|
||||
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
await blobClient.downloadBlob(blobId, name, type, accountId);
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
if (!client || !previewAttachment) return;
|
||||
const previewBlobClient = useCallback(() => {
|
||||
const id = previewAttachment?.clientAccountId;
|
||||
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
|
||||
}, [previewAttachment, client]);
|
||||
|
||||
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
}, [client, previewAttachment]);
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
const c = previewBlobClient();
|
||||
if (!c || !previewAttachment) return;
|
||||
|
||||
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||
}, [previewBlobClient, previewAttachment]);
|
||||
|
||||
const getPreviewAttachmentContent = useCallback(async () => {
|
||||
if (!client || !previewAttachment) {
|
||||
const c = previewBlobClient();
|
||||
if (!c || !previewAttachment) {
|
||||
throw new Error('No attachment selected');
|
||||
}
|
||||
|
||||
const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||
|
||||
return {
|
||||
blob,
|
||||
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
||||
};
|
||||
}, [client, previewAttachment]);
|
||||
}, [previewBlobClient, previewAttachment]);
|
||||
|
||||
const handleQuickReply = async (body: string) => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
const sender = selectedEmail.from?.[0];
|
||||
if (!sender?.email) {
|
||||
// Quick reply follows the same addressing rules as the composer: Reply-To
|
||||
// over From, and for our own messages in a thread the original recipients
|
||||
// instead of ourselves (#703).
|
||||
const ownIdentityEmails = identities.map(i => i.email).filter(Boolean);
|
||||
const replySource = {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
};
|
||||
const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
|
||||
.map(r => r.email)
|
||||
.filter((email): email is string => Boolean(email));
|
||||
if (recipients.length === 0) {
|
||||
throw new Error("No sender email found");
|
||||
}
|
||||
|
||||
@@ -2278,14 +2508,21 @@ export default function Home() {
|
||||
|
||||
// Decide the sending identity and (for domain-catch-all) an optional
|
||||
// header From override that matches the address the message was sent to.
|
||||
// Our own message keeps the identity it was sent from - the recipients are
|
||||
// the other party, so resolving from them would send as their address.
|
||||
// When the setting is off, fall through to primary-identity behavior.
|
||||
const resolved = autoSelectReplyIdentity
|
||||
? resolveReplyFrom(identities, {
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
})
|
||||
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
|
||||
? findDraftIdentityId(identities, selectedEmail.from?.[0])
|
||||
: null;
|
||||
const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity
|
||||
? null
|
||||
: selfSentIdentityId
|
||||
? { identityId: selfSentIdentityId }
|
||||
: resolveReplyFrom(identities, {
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
});
|
||||
const sendingIdentity = resolved
|
||||
? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity)
|
||||
: primaryIdentity;
|
||||
@@ -2332,7 +2569,7 @@ export default function Home() {
|
||||
// Send reply with just the body text
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
recipients,
|
||||
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||
finalBody,
|
||||
undefined,
|
||||
@@ -2413,14 +2650,12 @@ export default function Home() {
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = isScheduledView
|
||||
? t('sidebar.scheduled')
|
||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
||||
? t('sidebar.mailboxes.all_mail')
|
||||
: (() => {
|
||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||
return mb
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
: (() => {
|
||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||
return mb
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
@@ -2708,7 +2943,7 @@ export default function Home() {
|
||||
selectedKeyword={selectedKeyword}
|
||||
scheduledTotal={scheduledTotal}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
showAllMailMailbox={showAllMailMailbox}
|
||||
crossAccountActive={crossAccountActive}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
showCrossAll={showCrossAll}
|
||||
@@ -2835,8 +3070,8 @@ export default function Home() {
|
||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
disabled={isScheduledView}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -2852,15 +3087,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
disabled={isScheduledView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
||||
isScheduledView && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -3017,6 +3252,9 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* Plugin-registered category tabs (Gmail-style). Renders nothing
|
||||
unless an enabled plugin registered tabs via api.tabs.set. */}
|
||||
{!isScheduledView && <MessageListTabs />}
|
||||
<WelcomeBanner />
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
@@ -3073,6 +3311,10 @@ export default function Home() {
|
||||
selectEmail(email);
|
||||
handleForward();
|
||||
}}
|
||||
onForwardAsAttachment={(email) => {
|
||||
selectEmail(email);
|
||||
handleForwardAsAttachment(email);
|
||||
}}
|
||||
onMarkAsRead={async (email, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, email.id, read);
|
||||
@@ -3092,12 +3334,12 @@ export default function Home() {
|
||||
onArchive={async (email) => {
|
||||
await handleArchive(email);
|
||||
}}
|
||||
onSetColorTag={(emailId, color) => {
|
||||
handleSetColorTag(emailId, color);
|
||||
onSetTag={(emailId, color) => {
|
||||
handleSetTag(emailId, color);
|
||||
}}
|
||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||
if (client) {
|
||||
await moveToMailbox(client, emailId, mailboxId);
|
||||
await moveToMailboxCrossAware(client, emailId, mailboxId);
|
||||
}
|
||||
}}
|
||||
onMarkAsSpam={async (email) => {
|
||||
@@ -3195,8 +3437,7 @@ export default function Home() {
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
...getQuoteBodies(selectedEmail),
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
@@ -3304,6 +3545,7 @@ export default function Home() {
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onForwardAsAttachment={handleForwardAsAttachment}
|
||||
onDelete={() => {
|
||||
// Deleting the open message returns to the list (Gmail-style),
|
||||
// not the next email — unless the user turned the setting off.
|
||||
@@ -3317,7 +3559,7 @@ export default function Home() {
|
||||
}}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onSetTag={handleSetTag}
|
||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||
onUndoSpam={() => handleUndoSpam()}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
@@ -3368,7 +3610,7 @@ export default function Home() {
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMoveToMailbox={async (mailboxId) => {
|
||||
if (client && selectedEmail) {
|
||||
await moveToMailbox(client, selectedEmail.id, mailboxId);
|
||||
await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId);
|
||||
}
|
||||
}}
|
||||
className={isMobile ? "flex-1" : undefined}
|
||||
|
||||
@@ -26,6 +26,10 @@ export function PluginsTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
// Bundle held back by the pattern scanner, awaiting an explicit admin decision.
|
||||
const [pendingScan, setPendingScan] = useState<
|
||||
{ file: File; findings: Array<{ file: string; patterns: string[] }> } | null
|
||||
>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
@@ -104,15 +108,17 @@ export function PluginsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Upload a bundle. The scanner may refuse it for containing patterns that are
|
||||
// expected in a vendored crypto library (openpgp.js, pkijs); in that case the
|
||||
// server returns `canOverride` and we hold the file so the admin can review
|
||||
// the findings and decide. `override` re-posts the same file with consent.
|
||||
async function uploadPlugin(file: File, override: boolean) {
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (override) formData.append('overrideWarnings', 'true');
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
@@ -122,13 +128,22 @@ export function PluginsTab() {
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||
setPendingScan(null);
|
||||
const accepted = data.findings?.length
|
||||
? ` — ${data.findings.length} scanner finding(s) accepted and logged`
|
||||
: '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${accepted}` });
|
||||
await fetchPlugins();
|
||||
} else if (data.canOverride && Array.isArray(data.findings) && !override) {
|
||||
// Hold the file rather than the error: the admin needs to see WHAT
|
||||
// tripped, in WHICH file, before deciding.
|
||||
setPendingScan({ file, findings: data.findings });
|
||||
} else {
|
||||
setPendingScan(null);
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setPendingScan(null);
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -136,6 +151,13 @@ export function PluginsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPendingScan(null);
|
||||
await uploadPlugin(file, false);
|
||||
}
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
@@ -302,6 +324,51 @@ export function PluginsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingScan && (
|
||||
<div className="border border-warning/40 bg-warning/5 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Scanner flagged <span className="font-mono">{pendingScan.file.name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These patterns can indicate malicious code, but they also appear in legitimate
|
||||
minified crypto libraries such as openpgp.js and pkijs. Review the findings before
|
||||
proceeding — installing anyway is recorded in the audit log.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{pendingScan.findings.map(f => (
|
||||
<li key={f.file} className="text-xs font-mono bg-background/60 border border-border rounded px-2 py-1">
|
||||
<span className="text-foreground">{f.file}</span>
|
||||
<span className="text-muted-foreground"> — {f.patterns.join(', ')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => uploadPlugin(pendingScan.file, true)}
|
||||
disabled={uploading}
|
||||
className="inline-flex items-center gap-2 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <AlertTriangle className="w-3.5 h-3.5" />}
|
||||
Install anyway
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPendingScan(null); setMessage(null); }}
|
||||
disabled={uploading}
|
||||
className="inline-flex items-center h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
|
||||
// forward on policy load), so it is hidden from the admin UI.
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
@@ -22,10 +24,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { FaviconBadge } from "@/components/favicon-badge";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
@@ -132,6 +133,7 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<FaviconBadge />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/jmap
|
||||
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const directUrl = `${creds.serverUrl}/jmap/`;
|
||||
let response = await postJmap(directUrl, creds.authHeader, body);
|
||||
|
||||
if (response.status === 404) {
|
||||
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
|
||||
// (path prefix, non-Stalwart URL layout). Resolve the session's
|
||||
// advertised apiUrl on the same host and retry once.
|
||||
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
|
||||
if (apiUrl && apiUrl !== directUrl) {
|
||||
response = await postJmap(apiUrl, creds.authHeader, body);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart JMAP passthrough upstream error', {
|
||||
status: response.status,
|
||||
serverUrl: creds.serverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
return new NextResponse(responseText, {
|
||||
@@ -38,9 +51,27 @@ export async function POST(request: NextRequest) {
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof JmapRedirectError) {
|
||||
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||
}
|
||||
// `fetch failed` from undici is too generic to debug — the real reason
|
||||
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
|
||||
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
error: err?.message ?? 'Unknown',
|
||||
causeCode: err?.cause?.code,
|
||||
causeMessage: err?.cause?.message,
|
||||
});
|
||||
// The server this process failed to reach is the user's own mail server,
|
||||
// so the reason is worth surfacing: an opaque 500 leaves operators with
|
||||
// nothing to act on.
|
||||
if (err?.cause?.code) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot reach the JMAP server (${err.cause.code})` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,15 +158,47 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
const code = await entryFile.async('string');
|
||||
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
pattern.lastIndex = 0;
|
||||
// Security: scan for dangerous JS patterns across EVERY script in the
|
||||
// bundle, not just the entrypoint - a second .js file was previously never
|
||||
// looked at.
|
||||
//
|
||||
// The result is a reviewable finding rather than an unconditional reject.
|
||||
// Minified crypto libraries (openpgp.js, pkijs) legitimately contain these
|
||||
// patterns, so a hard block makes S/MIME and PGP plugins uninstallable.
|
||||
// This route is already admin-authenticated, so the scan is defence in
|
||||
// depth against an accidental or compromised upload, not a trust boundary:
|
||||
// an admin may proceed with `overrideWarnings`, and the override is
|
||||
// recorded in the audit log with the exact findings.
|
||||
const findings: Array<{ file: string; patterns: string[] }> = [];
|
||||
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||
if (entry.dir) continue;
|
||||
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||
if (ext !== '.js' && ext !== '.mjs') continue;
|
||||
const source = filePath === root + (manifest.entrypoint as string)
|
||||
? code
|
||||
: await entry.async('string');
|
||||
const hits: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(source)) hits.push(label);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (hits.length > 0) {
|
||||
findings.push({ file: filePath.slice(root.length), patterns: hits });
|
||||
}
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
|
||||
const overrideWarnings = formData.get('overrideWarnings') === 'true';
|
||||
if (findings.length > 0 && !overrideWarnings) {
|
||||
const summary = findings
|
||||
.map(f => `${f.file}: ${f.patterns.join(', ')}`)
|
||||
.join('; ');
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{
|
||||
error: `Plugin rejected: ${summary}. Review the bundle; if these are expected `
|
||||
+ `(e.g. a vendored crypto library), re-upload with "overrideWarnings" to proceed.`,
|
||||
findings,
|
||||
canOverride: true,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -212,8 +244,20 @@ export async function POST(request: NextRequest) {
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
if (findings.length > 0) {
|
||||
// Record WHAT was waved through, not merely that an override happened -
|
||||
// otherwise the audit trail can't answer "which patterns did we accept?".
|
||||
await auditLog(
|
||||
'plugin.install.scan_override',
|
||||
{ id: plugin.id, version: plugin.version, findings },
|
||||
ip,
|
||||
);
|
||||
logger.warn('Plugin installed with scanner override', { id: plugin.id, findings });
|
||||
}
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
// Echo accepted findings back so the admin UI can confirm exactly what was
|
||||
// waved through, rather than reporting a bare success.
|
||||
return NextResponse.json(findings.length > 0 ? { plugin, findings } : { plugin });
|
||||
} catch (error) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
|
||||
* Master-user impersonation via signed JWT. The token carries the target
|
||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||
* master credentials from env, then mints the same session cookies the
|
||||
* password-login path produces. The browser is redirected to "/" and the
|
||||
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
|
||||
* ImpersonationReconciler, GH #646) and the
|
||||
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||
*
|
||||
* Returns 404 when the feature is not configured so an unconfigured
|
||||
@@ -136,6 +137,6 @@ export async function GET(request: NextRequest) {
|
||||
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||
return new NextResponse(null, {
|
||||
status: 303,
|
||||
headers: { Location: '/' },
|
||||
headers: { Location: '/?impersonated=1' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { parseISO } from 'date-fns';
|
||||
@@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||
] as const;
|
||||
|
||||
interface JmapSession {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string;
|
||||
uid: string | null;
|
||||
@@ -141,9 +136,9 @@ export async function POST(request: NextRequest) {
|
||||
using.push('urn:ietf:params:jmap:principals:owner');
|
||||
}
|
||||
|
||||
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
||||
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
||||
const apiUrl = `${creds.serverUrl}/jmap/`;
|
||||
// Send method calls to the session's apiUrl rebased onto serverUrl's host
|
||||
// — never to session.apiUrl's (possibly unreachable) public host.
|
||||
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
|
||||
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
@@ -273,45 +268,12 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n
|
||||
return Math.min(max, Math.max(min, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
|
||||
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
|
||||
* fallback for other servers. Returns null if neither yields a usable session.
|
||||
*/
|
||||
async function fetchJmapSession(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<JmapSession | null> {
|
||||
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const session = (await res.json()) as JmapSession;
|
||||
if (session && typeof session === 'object' && session.primaryAccounts) {
|
||||
return session;
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
payload: unknown,
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
|
||||
if (!res.ok) {
|
||||
throw new Error(`JMAP request failed (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export async function GET(request: NextRequest) {
|
||||
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
|
||||
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
|
||||
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
|
||||
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
|
||||
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
|
||||
+561
-308
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* S/MIME certificate enrolment (`C-08`, server half).
|
||||
*
|
||||
* The plugin generates a keypair in the browser and sends only a CSR here. The
|
||||
* private key never leaves the device — this route never sees it and has no way
|
||||
* to ask for it.
|
||||
*
|
||||
* What this route exists to decide: **which addresses the issued certificate is
|
||||
* allowed to assert.** That question cannot be answered in the browser, and it
|
||||
* must not be answered by the CSR — a CSR is a self-assertion, and honouring its
|
||||
* `subjectAltName` would let anyone mint a certificate for any address, which is
|
||||
* indistinguishable from having no CA at all.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { CaError, getCaProvider } from '@/lib/smime-ca';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_CSR_BYTES = 8 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const provider = getCaProvider();
|
||||
if (!provider) {
|
||||
return NextResponse.json(
|
||||
{ error: 'S/MIME enrolment is not configured on this server' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: { csrPem?: unknown; slot?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const csrPem = typeof body.csrPem === 'string' ? body.csrPem.trim() : '';
|
||||
if (!csrPem) {
|
||||
return NextResponse.json({ error: 'csrPem is required' }, { status: 400 });
|
||||
}
|
||||
if (csrPem.length > MAX_CSR_BYTES) {
|
||||
return NextResponse.json({ error: 'csrPem too large' }, { status: 413 });
|
||||
}
|
||||
// Shape check only. This is not a security control — see the module comment on
|
||||
// why the CSR's contents are not trusted regardless of what they contain.
|
||||
if (!/^-----BEGIN (NEW )?CERTIFICATE REQUEST-----[\s\S]+-----END (NEW )?CERTIFICATE REQUEST-----$/
|
||||
.test(csrPem)) {
|
||||
return NextResponse.json({ error: 'csrPem is not a PEM PKCS#10 request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = Number.isInteger(body.slot) ? (body.slot as number) : 0;
|
||||
if (slot < 0 || slot > 9) {
|
||||
return NextResponse.json({ error: 'invalid slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// The auth context is an encrypted, server-minted cookie, so `username` cannot
|
||||
// be forged by the client. It still isn't sufficient on its own — see below.
|
||||
const auth = await readStalwartAuthContext(slot);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
let identity: { addresses: string[]; displayName?: string };
|
||||
try {
|
||||
identity = await resolveIdentity(auth.serverUrl, auth.authHeader);
|
||||
} catch (cause) {
|
||||
console.error('[smime-enroll] identity resolution failed:', cause);
|
||||
return NextResponse.json(
|
||||
{ error: 'could not confirm your sending addresses with the mail server' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (identity.addresses.length === 0) {
|
||||
// An authenticated principal with no sending identity — an admin-only
|
||||
// account, or a mailbox with submission disabled. Refuse rather than falling
|
||||
// back to the cookie's username, which would issue a certificate for an
|
||||
// address the mail server will not actually let this account send from.
|
||||
return NextResponse.json(
|
||||
{ error: 'this account has no sending address, so no certificate can be issued for it' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const issued = await provider.enroll({
|
||||
csrPem,
|
||||
addresses: identity.addresses,
|
||||
commonName: identity.displayName || identity.addresses[0],
|
||||
});
|
||||
|
||||
// Audit before returning. A certificate that exists with no record of who
|
||||
// asked for it is the thing you most want during an incident.
|
||||
console.info(
|
||||
`[smime-enroll] issued serial=${issued.serialNumber} ca=${provider.id} `
|
||||
+ `account=${auth.username} addresses=${identity.addresses.join(',')}`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
certificatePem: issued.certificatePem,
|
||||
chainPem: issued.chainPem,
|
||||
serialNumber: issued.serialNumber,
|
||||
issuerDn: issued.issuerDn,
|
||||
notAfter: issued.notAfter,
|
||||
addresses: identity.addresses,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CaError) {
|
||||
console.error(`[smime-enroll] CA error for ${auth.username}:`, error.message, error.cause);
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error('[smime-enroll] unexpected error:', error);
|
||||
return NextResponse.json({ error: 'enrolment failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Stalwart which addresses this session may send from, via `Identity/get`.
|
||||
*
|
||||
* This is deliberately not derived from the auth cookie's `username`. The right
|
||||
* authority for "may this person have a signing certificate for this address" is
|
||||
* the mail server that already decides "may this person send from this address" —
|
||||
* anything else invents a second, weaker answer to a question already settled.
|
||||
*
|
||||
* It also handles the cases the cookie cannot: an alias the account legitimately
|
||||
* sends as (which should be on the certificate) and an administrative principal
|
||||
* with no mailbox at all (which should get no certificate). The latter is not
|
||||
* hypothetical here — `admin@sandbox.vnc.de` authenticates successfully and has
|
||||
* no mail session, and trusting the cookie would have issued it a certificate.
|
||||
*/
|
||||
async function resolveIdentity(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<{ addresses: string[]; displayName?: string }> {
|
||||
const session = await fetchJmapSession(serverUrl, authHeader);
|
||||
if (!session) throw new Error('no JMAP session');
|
||||
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!accountId) throw new Error('no primary mail account');
|
||||
|
||||
const apiUrl = rebaseApiUrl(session, serverUrl);
|
||||
if (!apiUrl) throw new Error('session advertises no usable apiUrl');
|
||||
|
||||
const res = await postJmap(apiUrl, authHeader, JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'],
|
||||
methodCalls: [['Identity/get', { accountId }, '0']],
|
||||
}));
|
||||
if (!res.ok) throw new Error(`Identity/get returned ${res.status}`);
|
||||
|
||||
const payload = await res.json() as {
|
||||
methodResponses?: [string, { list?: { email?: string; name?: string }[] }, string][];
|
||||
};
|
||||
const first = payload.methodResponses?.[0];
|
||||
if (!first || first[0] !== 'Identity/get') {
|
||||
throw new Error('Identity/get failed');
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const addresses: string[] = [];
|
||||
let displayName: string | undefined;
|
||||
|
||||
for (const entry of first[1]?.list ?? []) {
|
||||
const email = typeof entry.email === 'string' ? entry.email.trim().toLowerCase() : '';
|
||||
// Stalwart can report a wildcard identity (`*@domain`) for accounts allowed
|
||||
// to send as anything in a domain. That is a real capability, but it is not
|
||||
// an address and must never reach a certificate — a `rfc822Name` SAN of
|
||||
// `*@vnc.de` is either rejected by clients or, worse, honoured.
|
||||
if (!email || email.includes('*') || !email.includes('@')) continue;
|
||||
if (seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
addresses.push(email);
|
||||
if (!displayName && typeof entry.name === 'string' && entry.name.trim()) {
|
||||
displayName = entry.name.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { addresses, displayName };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -558,6 +559,40 @@ body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Shake animation (for rejected input) */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
/* Fade in animation (for popovers) */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Slide in from right animation (for mobile views) */
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { FaviconBadge } from '@/components/favicon-badge';
|
||||
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-favicon-badge', () => ({
|
||||
useFaviconBadge: vi.fn(),
|
||||
}));
|
||||
|
||||
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
|
||||
|
||||
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
|
||||
return {
|
||||
name: patch.id,
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
isSubscribed: true,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
...patch,
|
||||
} as Mailbox;
|
||||
}
|
||||
|
||||
const initialMailboxes = useEmailStore.getState().mailboxes;
|
||||
|
||||
beforeEach(() => {
|
||||
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('FaviconBadge', () => {
|
||||
it('badges the unread count of the primary inbox', () => {
|
||||
useEmailStore.setState({
|
||||
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||
});
|
||||
|
||||
const { container } = render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
|
||||
expect(container.firstChild).toBeNull(); // renders no markup
|
||||
});
|
||||
|
||||
it('disables the badge when the setting is off', () => {
|
||||
useSettingsStore.setState({ faviconUnreadBadge: false });
|
||||
useEmailStore.setState({
|
||||
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||
});
|
||||
|
||||
render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
|
||||
});
|
||||
|
||||
it('ignores a shared inbox, even when it sorts first', () => {
|
||||
// Shared and group inboxes ship in the same `mailboxes` array. A plain
|
||||
// `role === 'inbox'` lookup would badge somebody else's inbox on a
|
||||
// delegated setup, so the store's canonical `!isShared` filter is required.
|
||||
useEmailStore.setState({
|
||||
mailboxes: [
|
||||
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
|
||||
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
|
||||
});
|
||||
|
||||
it('badges zero when there is no inbox yet', () => {
|
||||
useEmailStore.setState({ mailboxes: [] });
|
||||
|
||||
render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
@@ -45,6 +46,13 @@ export function CalendarMonthView({
|
||||
pendingPreview,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
// On mobile the month view collapses events to dots unless the user opted
|
||||
// into full entries via "Show time in month view" (#666).
|
||||
const showChips = !isMobile || showTimeInMonthView;
|
||||
const overlayTop = isMobile ? 34 : 30;
|
||||
const rowHeight = isMobile ? 18 : 22;
|
||||
const chipHeight = rowHeight - 2;
|
||||
const {
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
@@ -157,7 +165,7 @@ export function CalendarMonthView({
|
||||
<div key={wi} className={cn(
|
||||
"relative flex-1 border-b border-border last:border-b-0",
|
||||
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
||||
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
||||
)} role="row" style={showChips ? { minHeight: Math.max(isMobile ? 52 : 100, overlayTop + 4 + rowCount * rowHeight + 8) } : undefined}>
|
||||
<div className="grid grid-cols-7 h-full">
|
||||
{week.map((day) => {
|
||||
const inMonth = checkIsSameMonth(day, selectedDate);
|
||||
@@ -201,7 +209,7 @@ export function CalendarMonthView({
|
||||
{formatDayNumber(day)}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
{isMobile && !showChips ? (
|
||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||
{dayEvents.slice(0, 3).map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
@@ -231,25 +239,28 @@ export function CalendarMonthView({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isMobile && pendingPreview && (() => {
|
||||
{showChips && pendingPreview && (() => {
|
||||
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
||||
if (previewDayIdx === -1) return null;
|
||||
const previewRow = rowCount;
|
||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
|
||||
<div
|
||||
className="absolute px-0.5"
|
||||
style={{
|
||||
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||
top: previewRow * 22,
|
||||
height: 20,
|
||||
top: previewRow * rowHeight,
|
||||
height: chipHeight,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
||||
className={cn(
|
||||
"h-full rounded text-[10px] font-medium truncate border-2 border-dashed",
|
||||
isMobile ? "leading-[16px] px-1" : "leading-[20px] px-1.5"
|
||||
)}
|
||||
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||
>
|
||||
{pendingPreview.title}
|
||||
@@ -259,8 +270,8 @@ export function CalendarMonthView({
|
||||
);
|
||||
})()}
|
||||
|
||||
{!isMobile && segments.length > 0 && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||
{showChips && segments.length > 0 && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
|
||||
{segments.map((segment) => {
|
||||
const calId = getPrimaryCalendarId(segment.event);
|
||||
return (
|
||||
@@ -270,8 +281,8 @@ export function CalendarMonthView({
|
||||
style={{
|
||||
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
||||
top: segment.row * 22,
|
||||
height: 20,
|
||||
top: segment.row * rowHeight,
|
||||
height: chipHeight,
|
||||
}}
|
||||
>
|
||||
<EventCard
|
||||
@@ -285,6 +296,7 @@ export function CalendarMonthView({
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -201,7 +201,7 @@ export function CalendarToolbar({
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
</button>
|
||||
{showCalendarDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
@@ -335,7 +335,7 @@ export function CalendarToolbar({
|
||||
<ChevronDown className="w-3 h-3 ms-1" />
|
||||
</Button>
|
||||
{showImportDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
||||
{onImport && (
|
||||
<button
|
||||
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
||||
|
||||
@@ -102,4 +102,98 @@ describe('ContactForm', () => {
|
||||
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('saves an organization-only card when the organization type is selected', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('type_organization'));
|
||||
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const savedData = onSave.mock.calls[0][0];
|
||||
expect(savedData.kind).toBe('org');
|
||||
expect(savedData.organizations.o0.name).toBe('Acme Corp');
|
||||
// No personal name components; the org name carries the display name instead.
|
||||
expect(savedData.name.components).toBeUndefined();
|
||||
expect(savedData.name.full).toBe('Acme Corp');
|
||||
});
|
||||
|
||||
it('hides the personal name fields in organization mode', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('type_organization'));
|
||||
|
||||
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument();
|
||||
// The organization field moves into the identity section, so it appears once.
|
||||
expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still requires a name in organization mode', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('type_organization'));
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('name_required')).toBeInTheDocument();
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts an organization instead of a personal name in person mode', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('section_work'));
|
||||
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp');
|
||||
});
|
||||
|
||||
it('opens an existing org card in organization mode', () => {
|
||||
const orgContact: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: {},
|
||||
kind: 'org',
|
||||
name: { full: 'Acme Corp' },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
};
|
||||
render(<ContactForm contact={orgContact} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches an org card back to a person', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const orgContact: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: {},
|
||||
kind: 'org',
|
||||
name: { full: 'Acme Corp' },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
};
|
||||
render(<ContactForm contact={orgContact} onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('type_person'));
|
||||
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onSave.mock.calls[0][0].kind).toBe('individual');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
const hasNickname = nicknames.length > 0;
|
||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||
// On an organization card the org name is already the heading; don't repeat it.
|
||||
const orgName = orgs[0]?.name;
|
||||
const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[];
|
||||
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||
@@ -571,7 +573,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.separator) {
|
||||
|
||||
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
|
||||
);
|
||||
|
||||
// A card may describe an organization instead of a person (RFC 9553 kind "org").
|
||||
// Older cards predate the explicit kind, so fall back to "has an org name but no
|
||||
// personal name".
|
||||
const [isOrg, setIsOrg] = useState(() => {
|
||||
if (!contact) return false;
|
||||
if (contact.kind) return contact.kind === "org";
|
||||
const hasPersonName = !!(findComponent("given") || findComponent("surname"));
|
||||
return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name;
|
||||
});
|
||||
|
||||
const [jobTitle, setJobTitle] = useState(() => {
|
||||
if (contact?.titles) {
|
||||
const t = Object.values(contact.titles).find(t => t.kind !== "role");
|
||||
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!givenName.trim() && !surname.trim()) {
|
||||
// An organization name identifies the card just as well as a personal name.
|
||||
const orgName = organization.trim();
|
||||
const hasPersonName = !!(givenName.trim() || surname.trim());
|
||||
if (isOrg ? !orgName : (!hasPersonName && !orgName)) {
|
||||
setError(t("name_required"));
|
||||
return;
|
||||
}
|
||||
@@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||
const nameComponents = [];
|
||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||
if (!isOrg) {
|
||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||
}
|
||||
|
||||
// Without personal name components, carry the organization name in `name.full`
|
||||
// so servers and other clients have something to display.
|
||||
const nameValue: ContactCard["name"] = nameComponents.length > 0
|
||||
? { components: nameComponents, isOrdered: true }
|
||||
: { full: orgName };
|
||||
|
||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||
@@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const mediaValue: Record<string, ContactMedia> | null | undefined =
|
||||
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
|
||||
|
||||
// Only send `kind` when this form owns the answer: switching a card between
|
||||
// person and organization. Leave other kinds (group, location, ...) untouched.
|
||||
const kindValue: ContactCard["kind"] | undefined =
|
||||
isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined);
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
name: nameValue,
|
||||
...(kindValue ? { kind: kindValue } : {}),
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
||||
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
||||
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
||||
organizations: organization.trim()
|
||||
? { o0: { name: organization.trim(), units: orgUnits } }
|
||||
organizations: orgName
|
||||
? { o0: { name: orgName, units: orgUnits } }
|
||||
: undefined,
|
||||
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
||||
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
|
||||
@@ -570,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
}
|
||||
};
|
||||
|
||||
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
|
||||
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||
|
||||
return (
|
||||
@@ -661,38 +688,81 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
)}
|
||||
|
||||
<FormSection icon={User} title={t("section_identity")}>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("given_name")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("surname")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
|
||||
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
|
||||
{[
|
||||
{ org: false, label: t("type_person"), icon: User },
|
||||
{ org: true, label: t("type_organization"), icon: Building },
|
||||
].map(({ org, label, icon: Icon }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isOrg === org}
|
||||
onClick={() => setIsOrg(org)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
|
||||
isOrg === org
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
|
||||
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
|
||||
{isOrg ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("organization")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
|
||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
|
||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("given_name")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("surname")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
|
||||
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
|
||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* Email */}
|
||||
@@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</FormSection>
|
||||
|
||||
{/* Work & Organization */}
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
|
||||
</div>
|
||||
{/* In organization mode the org name is the card's identity, edited above. */}
|
||||
{!isOrg && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
|
||||
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
|
||||
|
||||
@@ -284,7 +284,7 @@ export function ContactsSidebar({
|
||||
{showMenu && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||
className="absolute end-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EmailListItem } from '../email-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Mock the drag hook
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
// Mock identity badge
|
||||
vi.mock('../email-identity-badge', () => ({
|
||||
EmailIdentityBadge: () => null,
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('EmailListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show tag badge when email has no label keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
// No keyword label should appear
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tag badge with label when email has $label: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tag badge for legacy $color: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
// Unknown tags fall back to the raw id as label with a gray dot
|
||||
// (see email-list-item.tsx: keywordDefs fallback).
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows custom keyword label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
...DEFAULT_KEYWORDS,
|
||||
{ id: 'work', label: 'Work', color: 'teal' },
|
||||
],
|
||||
});
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates badge when keyword definition changes', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
// Update label name
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
rerender(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders subject even without tag', () => {
|
||||
const email = makeEmail({ subject: 'Hello World' });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inline preview text in focused mail layout', () => {
|
||||
useSettingsStore.setState({
|
||||
showPreview: true,
|
||||
mailLayout: 'focus',
|
||||
});
|
||||
const email = makeEmail({ preview: 'Inline preview content' });
|
||||
const { container } = render(<EmailListItem email={email} />);
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// selection mode active (so the checkbox renders), anchor on e1
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
render(<EmailListItem email={e3} />);
|
||||
// the checkbox is the first button in the row (shown in selection mode)
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const sel = useEmailStore.getState().selectedEmailIds;
|
||||
expect(sel.has('e1')).toBe(true);
|
||||
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
||||
expect(sel.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
@@ -148,7 +149,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/reply-identity', () => ({
|
||||
resolveReplyFrom: () => null,
|
||||
findComposeIdentityId: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
@@ -226,8 +230,10 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
|
||||
// The enrichment pass may have stamped extra display metadata on the
|
||||
// chip by drag time, so match the essential fields rather than deep-equal.
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
|
||||
expect(payload).toMatchObject({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
|
||||
});
|
||||
|
||||
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||
@@ -241,7 +247,7 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
|
||||
expect(payload).toMatchObject({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
|
||||
});
|
||||
|
||||
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||
@@ -340,4 +346,121 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
const ccLabel = await screen.findByText('cc_label');
|
||||
expect(ccLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ─── Reordering within / across fields (#593) ─────────────────────────────────
|
||||
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
|
||||
// read-only MouseEvent getter) and gives every element a zero-size rect at
|
||||
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
|
||||
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
|
||||
|
||||
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
|
||||
|
||||
const chipByText = async (text: string) =>
|
||||
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
|
||||
|
||||
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
|
||||
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(e, 'clientX', { value: clientX });
|
||||
Object.defineProperty(e, 'dataTransfer', { value: dt });
|
||||
act(() => { fireEvent(el, e); });
|
||||
};
|
||||
const BEFORE = -100;
|
||||
const AFTER = 100;
|
||||
|
||||
/** Ordered chip labels of the field-container that holds `anchorText`. */
|
||||
const orderIn = (anchorText: string) => {
|
||||
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||
const c = containers.find(el =>
|
||||
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
|
||||
) as HTMLElement;
|
||||
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
|
||||
};
|
||||
|
||||
/** All draggable chips (across fields) whose label contains `text`. */
|
||||
const draggableChipsWith = (text: string) =>
|
||||
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
|
||||
|
||||
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
const alice = await chipByText('alice@example.com');
|
||||
const carol = await chipByText('carol@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
|
||||
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
|
||||
fireDnd('drop', carol, dt, AFTER);
|
||||
|
||||
expect(orderIn('bob@example.com')).toEqual([
|
||||
'bob@example.com', 'carol@example.com', 'alice@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('carol@example.com');
|
||||
const carol = await chipByText('carol@example.com');
|
||||
const alice = await chipByText('alice@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
|
||||
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
|
||||
fireDnd('drop', alice, dt, BEFORE);
|
||||
|
||||
expect(orderIn('alice@example.com')).toEqual([
|
||||
'carol@example.com', 'alice@example.com', 'bob@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('dropping a chip onto its own position leaves the order unchanged', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('bob@example.com');
|
||||
const bob = await chipByText('bob@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
|
||||
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
|
||||
fireDnd('drop', bob, dt, BEFORE);
|
||||
|
||||
expect(orderIn('bob@example.com')).toEqual([
|
||||
'alice@example.com', 'bob@example.com', 'carol@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
|
||||
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
const alice = await chipByText('alice@example.com'); // To
|
||||
const y = await chipByText('y@example.com'); // Cc
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
|
||||
fireDnd('drop', y, dt, BEFORE);
|
||||
|
||||
// alice lands between x and y; To no longer holds it (count only real chips,
|
||||
// not the leftover jsdom drag-preview element)
|
||||
expect(orderIn('x@example.com')).toEqual([
|
||||
'x@example.com', 'alice@example.com', 'y@example.com',
|
||||
]);
|
||||
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows a drop caret only while a chip is dragged over the field', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
const alice = await chipByText('alice@example.com');
|
||||
const bob = await chipByText('bob@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||
|
||||
fireDnd('dragover', bob, dt, BEFORE);
|
||||
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
|
||||
|
||||
fireEvent.dragEnd(alice);
|
||||
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
@@ -147,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/reply-identity', () => ({
|
||||
resolveReplyFrom: () => null,
|
||||
findComposeIdentityId: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ─────────────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ ref: { current: null } }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
|
||||
{ id: 'id-info', email: 'info@example.com', name: 'Info' },
|
||||
],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const RECEIVED = {
|
||||
from: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
to: [{ email: 'me@example.com', name: 'Me' }, { email: 'carol@other.com', name: 'Carol' }],
|
||||
cc: [{ email: 'dave@other.com', name: 'Dave' }],
|
||||
subject: 'Hello',
|
||||
};
|
||||
|
||||
/** The same conversation, but the message opened is the one we sent back. */
|
||||
const SELF_SENT = {
|
||||
from: [{ email: 'me@example.com', name: 'Me' }],
|
||||
to: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
cc: [{ email: 'carol@other.com', name: 'Carol' }],
|
||||
subject: 'Re: Hello',
|
||||
};
|
||||
|
||||
/** Chip labels currently shown in a recipient row, in order. Chips are the
|
||||
* draggable spans inside the row; next-intl is mocked to return the key, so
|
||||
* the Cc row is found via its "cc_label" caption. */
|
||||
const chipsIn = (row: HTMLElement) =>
|
||||
Array.from(row.querySelectorAll('[draggable]')).map((el) => el.textContent?.trim());
|
||||
|
||||
const toChips = () => chipsIn(screen.getByTestId('composer-to'));
|
||||
const ccChips = () => chipsIn(screen.getByText('cc_label').parentElement as HTMLElement);
|
||||
|
||||
const identitySelect = () => screen.getByTestId('composer-from') as HTMLSelectElement;
|
||||
|
||||
describe('composer reply addressing', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('addresses a reply to the sender of a received message', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={RECEIVED} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)']);
|
||||
});
|
||||
|
||||
it('reply-all keeps the other recipients but not our own address', () => {
|
||||
render(<EmailComposer mode="replyAll" replyTo={RECEIVED} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']);
|
||||
expect(ccChips()).toEqual(['Dave (dave@other.com)']);
|
||||
});
|
||||
|
||||
// #703: replying to our own message inside a thread used to address the
|
||||
// reply back to ourselves instead of continuing the conversation.
|
||||
it('addresses a reply to our own message to the original recipient', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={SELF_SENT} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)']);
|
||||
});
|
||||
|
||||
it('reply-all on our own message restores the original To and Cc', () => {
|
||||
render(<EmailComposer mode="replyAll" replyTo={SELF_SENT} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)']);
|
||||
expect(ccChips()).toEqual(['Carol (carol@other.com)']);
|
||||
});
|
||||
|
||||
it('sends the reply to our own message from the identity that sent it', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={{ ...SELF_SENT, from: [{ email: 'info@example.com', name: 'Info' }] }} />);
|
||||
expect(identitySelect().value).toBe('id-info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagBadge } from '../tag-badge';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
];
|
||||
|
||||
describe('TagBadge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names the tag by its full path', () => {
|
||||
render(<TagBadge tagId="work/clients" variant="badge" />);
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag it has no definition for by its id', () => {
|
||||
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
|
||||
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers removal only when asked to', () => {
|
||||
const onRemove = vi.fn();
|
||||
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves the dot alone, having nowhere to put the control', () => {
|
||||
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Work')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagPicker } from '../tag-picker';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
|
||||
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||
];
|
||||
|
||||
/** Ten tags is the point at which the filter box appears. */
|
||||
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
label: i === 0 ? 'Invoices' : `Tag ${i}`,
|
||||
color: 'blue',
|
||||
}));
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names a nested tag by its own label, not the whole path', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
// The tree conveys the hierarchy, so a child needs only its own name.
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indents each level below its parent', () => {
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
const acme = screen.getByText('Acme');
|
||||
|
||||
// Two levels down: two nested indent wrappers between it and the list.
|
||||
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
|
||||
expect(indents).not.toBeNull();
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the applied tags and reports toggles by id', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('Clients').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('work/clients');
|
||||
});
|
||||
|
||||
it('lists a tag it has no definition for, so it can be taken off', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('from-elsewhere').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
|
||||
|
||||
// Nothing but the message says it exists, so deselecting is the last of it.
|
||||
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
|
||||
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts undefined tags towards the filter box, and matches them', () => {
|
||||
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
|
||||
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
|
||||
expect(within(container).getByText('stray-3')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the filter box until the list is long enough to need one', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
|
||||
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flattens to matches while filtering, and says so when there are none', () => {
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
|
||||
expect(within(container).getByText('Invoices')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
|
||||
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches the full path, so a child is reachable by its parent name', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
|
||||
// Filtered rows are flat, so they carry the whole path.
|
||||
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists tags flat when nesting is off', () => {
|
||||
useSettingsStore.setState({ nestedTags: false });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(0);
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A one-message thread, built through the real grouping so the fixture cannot
|
||||
* drift from what the list actually feeds this component. `ThreadListItem`
|
||||
* delegates to `SingleEmailItem` at that size, which is what draws every
|
||||
* single-message row in the app.
|
||||
*/
|
||||
function renderRow(email: Email) {
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThreadListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show a tag badge when the email has no label keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a tag badge for a $label: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a tag badge for the legacy $color: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
|
||||
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw id when the tag is not in settings', () => {
|
||||
// A keyword created by another client, or one whose definition was deleted.
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
|
||||
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a custom tag label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
|
||||
});
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
|
||||
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a renamed tag definition', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = renderRow(email);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
rerender(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem multi-message thread', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
function renderThread(emails: Email[], expanded = false) {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expanded}
|
||||
expandedEmails={expanded ? emails : undefined}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('carries the tags of every message, not just the first', () => {
|
||||
// A collapsed row stands in for the whole thread, so a tag applied only to
|
||||
// a later message still has to surface.
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag shared by several messages once', () => {
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText('Red')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows each message its own tags once the thread is expanded', () => {
|
||||
renderThread(
|
||||
[
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
// Once on the header and once on the message that carries it.
|
||||
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row content', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the subject without a tag', () => {
|
||||
renderRow(makeEmail({ subject: 'Hello World' }));
|
||||
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders preview text inline in the focused layout', () => {
|
||||
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
|
||||
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
// Focused rows are one line: the preview shares the subject's element
|
||||
// rather than getting a paragraph of its own.
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// Selection mode active so the checkbox renders, with the anchor on e1.
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
renderRow(e3);
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const selected = useEmailStore.getState().selectedEmailIds;
|
||||
expect(selected.has('e1')).toBe(true);
|
||||
expect(selected.has('e2')).toBe(true); // the row in between got filled in
|
||||
expect(selected.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row tint', () => {
|
||||
const rowClasses = (container: HTMLElement) =>
|
||||
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
tintListRowsByTag: true,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set(['email-1']),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a checked row tinted, and says so to either theme', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-red-50');
|
||||
expect(classes).toContain('dark:bg-red-950/30');
|
||||
expect(classes).not.toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('washes a checked row that has no tint to keep', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('leaves the tint alone when the setting is off', () => {
|
||||
useSettingsStore.setState({ tintListRowsByTag: false });
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).not.toContain('bg-red-50');
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { isDocumentRTL } from '@/i18n/direction';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
||||
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||
@@ -1026,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
}
|
||||
if (pickerTriggerRef.current) {
|
||||
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
||||
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
|
||||
setPickerPosition(
|
||||
isDocumentRTL()
|
||||
? { top: rect.bottom + 4, right: window.innerWidth - rect.right }
|
||||
: { top: rect.bottom + 4, left: rect.left }
|
||||
);
|
||||
}
|
||||
setShowCalendarPicker(true);
|
||||
}}
|
||||
@@ -1041,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
||||
style={{ top: pickerPosition.top, left: pickerPosition.left }}
|
||||
style={{ top: pickerPosition.top, left: pickerPosition.left, right: pickerPosition.right }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{t('select_calendar')}
|
||||
|
||||
+333
-103
@@ -5,15 +5,16 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
@@ -26,16 +27,17 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders } from "@/lib/template-utils";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature } from "@/lib/template-utils";
|
||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
@@ -44,16 +46,20 @@ import {
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
expandRecipients,
|
||||
splitPastedRecipients,
|
||||
waitForPendingUploads,
|
||||
extractUserAuthoredText,
|
||||
type Recipient,
|
||||
enrichChipsWithColorsAndIcons,
|
||||
ICON_MAP,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
|
||||
/**
|
||||
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||
@@ -92,6 +98,10 @@ function createChipDragPreview(label: string): HTMLElement {
|
||||
return preview;
|
||||
}
|
||||
|
||||
// An autocomplete entry: a person, or a contact group (empty email) that
|
||||
// inserts as a single chip and expands into its members on send.
|
||||
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
|
||||
|
||||
export interface ComposerDraftData {
|
||||
to: string;
|
||||
cc: string;
|
||||
@@ -285,6 +295,9 @@ export function EmailComposer({
|
||||
: [];
|
||||
const primaryIdentity = activeIdentities[0] ?? null;
|
||||
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||
@@ -310,31 +323,17 @@ export function EmailComposer({
|
||||
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
|
||||
({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" });
|
||||
|
||||
const ownIdentityEmails = identities.map(i => i.email).filter((e): e is string => Boolean(e));
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = (): Recipient[] => {
|
||||
if (!replyTo) return [];
|
||||
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||
const replyTarget = replyTo.replyToAddresses?.length
|
||||
? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient)
|
||||
: (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []);
|
||||
if (mode === 'reply') {
|
||||
return replyTarget;
|
||||
} else if (mode === 'replyAll') {
|
||||
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
|
||||
const originalTo = (replyTo.to ?? [])
|
||||
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
|
||||
.map(toRecipient);
|
||||
return [...replyTarget, ...originalTo];
|
||||
}
|
||||
return [];
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return [];
|
||||
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient);
|
||||
};
|
||||
|
||||
const getInitialCc = (): Recipient[] => {
|
||||
if (!replyTo || mode !== 'replyAll') return [];
|
||||
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
|
||||
return (replyTo.cc ?? [])
|
||||
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
|
||||
.map(toRecipient);
|
||||
if (mode !== 'replyAll') return [];
|
||||
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient);
|
||||
};
|
||||
|
||||
const getInitialSubject = () => {
|
||||
@@ -704,6 +703,18 @@ export function EmailComposer({
|
||||
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
// Replying to our own message in a thread (#703): keep sending as the
|
||||
// identity that sent it. Resolving from the recipients here would pick the
|
||||
// *other* party's address - and on a catch-all domain it would even set a
|
||||
// From override to their address.
|
||||
if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) {
|
||||
const senderIdentityId = findDraftIdentityId(identities, replyTo?.from?.[0]);
|
||||
if (senderIdentityId) {
|
||||
setSelectedIdentityId(senderIdentityId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
to: replyTo?.to,
|
||||
cc: replyTo?.cc,
|
||||
@@ -743,6 +754,7 @@ export function EmailComposer({
|
||||
replyTo?.accountId,
|
||||
replyTo?.bcc,
|
||||
replyTo?.cc,
|
||||
replyTo?.from,
|
||||
replyTo?.to,
|
||||
selectedIdentityId,
|
||||
]);
|
||||
@@ -817,12 +829,33 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [composerClient, plainTextMode, mode]);
|
||||
|
||||
const processEnrichment = async (
|
||||
recipients: Recipient[],
|
||||
setRecipients: (items: Recipient[]) => void
|
||||
) => {
|
||||
const hasUnenriched = recipients.some((r) => !r.extra?.enriched);
|
||||
if (!hasUnenriched) return;
|
||||
|
||||
const newChips = await enrichChipsWithColorsAndIcons(recipients);
|
||||
const fullyEnriched = newChips.map((chip) => ({
|
||||
...chip,
|
||||
extra: { ...chip.extra, enriched: true },
|
||||
}));
|
||||
setRecipients(fullyEnriched);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => { processEnrichment(to, setTo); }, [to]);
|
||||
useEffect(() => { processEnrichment(cc, setCc); }, [cc]);
|
||||
useEffect(() => { processEnrichment(bcc, setBcc); }, [bcc]);
|
||||
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
? `<div>${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const getGroupMembers = useContactStore((s) => s.getGroupMembers);
|
||||
const searchRecipients = useContactStore((s) => s.searchRecipients);
|
||||
// Whether a Sent mailbox is known so the on-demand server search is worth
|
||||
// offering (falls back to hiding the "search the server" row otherwise).
|
||||
@@ -899,7 +932,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<SuggestionItem>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
// Current trimmed query behind the open dropdown, plus the in-flight flag for
|
||||
@@ -930,15 +963,26 @@ export function EmailComposer({
|
||||
}
|
||||
}, [plainTextMode]);
|
||||
|
||||
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
|
||||
// Move a chip from one recipient field to another. `toIndex`, when given,
|
||||
// inserts at that position in the destination (drag-and-drop reordering,
|
||||
// #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
|
||||
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
|
||||
if (fromField === toField) return;
|
||||
const setters = { to: setTo, cc: setCc, bcc: setBcc };
|
||||
const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
|
||||
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
|
||||
const sameRecipient = (a: Recipient, b: Recipient) =>
|
||||
a.email === b.email && (a.name ?? '') === (b.name ?? '') && groupKey(a) === groupKey(b);
|
||||
setters[fromField](prev => {
|
||||
const idx = prev.findIndex(r => sameRecipient(r, recipient));
|
||||
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
|
||||
setters[toField](prev => {
|
||||
if (prev.some(r => sameRecipient(r, recipient))) return prev;
|
||||
const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
|
||||
const next = [...prev];
|
||||
next.splice(at, 0, recipient);
|
||||
return next;
|
||||
});
|
||||
if (toField === 'cc') setShowCc(true);
|
||||
if (toField === 'bcc') setShowBcc(true);
|
||||
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
|
||||
@@ -961,9 +1005,9 @@ export function EmailComposer({
|
||||
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||
const localResults = getAutocomplete(query);
|
||||
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email, group: r.group }));
|
||||
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email, group: s.group })));
|
||||
// Keep the dropdown open even without local hits when a server search is
|
||||
// available, so the "search the server" row stays reachable (OWA-style).
|
||||
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
|
||||
@@ -998,11 +1042,30 @@ export function EmailComposer({
|
||||
}
|
||||
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
|
||||
|
||||
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
|
||||
const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => {
|
||||
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
|
||||
|
||||
setter(prev => [...prev, toRecipient(suggestion)]);
|
||||
if (suggestion.group) {
|
||||
// Insert the group as a single chip carrying a snapshot of its members
|
||||
// (deduped, members without an address skipped). The chip is expanded
|
||||
// into the members when the message is sent or saved as a draft.
|
||||
const seen = new Set<string>();
|
||||
const members: Array<{ name?: string; email: string }> = [];
|
||||
for (const m of getGroupMembers(suggestion.group.id)) {
|
||||
const email = getContactPrimaryEmail(m).trim();
|
||||
const key = email.toLowerCase();
|
||||
if (!email || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const name = getContactDisplayName(m);
|
||||
members.push({ name: name && name !== email ? name : undefined, email });
|
||||
}
|
||||
if (members.length > 0) {
|
||||
setter(prev => [...prev, { name: suggestion.name, email: '', group: { members } }]);
|
||||
}
|
||||
} else {
|
||||
setter(prev => [...prev, toRecipient(suggestion)]);
|
||||
}
|
||||
inputSetter('');
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
@@ -1052,13 +1115,22 @@ export function EmailComposer({
|
||||
: template.body;
|
||||
|
||||
// In plain text mode, use template body as-is; otherwise convert to HTML
|
||||
const bodyContent = plainTextMode
|
||||
const bodyContent = plainTextMode || template.isHTML
|
||||
? filledBody
|
||||
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||
|
||||
if (mode === 'compose') {
|
||||
setSubject(filledSubject);
|
||||
setBody(bodyContent);
|
||||
// Compose bodies carry the embedded signature (see
|
||||
// shouldEmbedSignatureInNewMail) and the send path assumes it stays
|
||||
// there, so replace only the message content, not the signature block.
|
||||
if (plainTextMode) {
|
||||
setBody(shouldEmbedSignatureInNewMail
|
||||
? appendPlainTextSignature(bodyContent, signatureIdentity, { separator: signatureSeparatorEnabled })
|
||||
: bodyContent);
|
||||
} else {
|
||||
setBody((prev) => spliceTemplateAboveSignature(prev, bodyContent));
|
||||
}
|
||||
if (template.defaultRecipients?.to?.length) {
|
||||
setTo(template.defaultRecipients.to.map(parseRecipient));
|
||||
}
|
||||
@@ -1071,7 +1143,30 @@ export function EmailComposer({
|
||||
setShowBcc(true);
|
||||
}
|
||||
} else {
|
||||
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
|
||||
// Reply/forward: insert the template at the caret so it lands after any
|
||||
// text the user has already typed, instead of always prepending it (#539).
|
||||
if (plainTextMode) {
|
||||
const textarea = bodyRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart ?? textarea.value.length;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
setBody((prev) => prev.slice(0, start) + bodyContent + prev.slice(end));
|
||||
// Restore the caret just past the inserted text once React re-renders.
|
||||
requestAnimationFrame(() => {
|
||||
const caret = start + bodyContent.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(caret, caret);
|
||||
});
|
||||
} else {
|
||||
setBody((prev) => bodyContent + '\n' + prev);
|
||||
}
|
||||
} else if (editorRef.current) {
|
||||
// insertContent lands at the editor's current selection; onUpdate
|
||||
// propagates the resulting HTML back through onChange → setBody.
|
||||
editorRef.current.chain().focus().insertContent(bodyContent).run();
|
||||
} else {
|
||||
setBody((prev) => bodyContent + prev);
|
||||
}
|
||||
}
|
||||
|
||||
if (template.identityId) {
|
||||
@@ -1079,14 +1174,14 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
setShowTemplatePicker(false);
|
||||
}, [mode, plainTextMode]);
|
||||
}, [mode, plainTextMode, shouldEmbedSignatureInNewMail, signatureIdentity, signatureSeparatorEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
if (target?.getAttribute('contenteditable') === 'true') return;
|
||||
// composedPath-based check so editing inside the QuotedHtml shadow
|
||||
// island doesn't trigger the picker (#654).
|
||||
if (isEditableEventTarget(e)) return;
|
||||
if (!templatesEnabled) return;
|
||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
setShowTemplatePicker(true);
|
||||
@@ -1094,7 +1189,7 @@ export function EmailComposer({
|
||||
};
|
||||
window.addEventListener('keydown', handleTemplateKey);
|
||||
return () => window.removeEventListener('keydown', handleTemplateKey);
|
||||
}, []);
|
||||
}, [templatesEnabled]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (!client || files.length === 0) return;
|
||||
@@ -1303,9 +1398,9 @@ export function EmailComposer({
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
|
||||
const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
|
||||
const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
|
||||
const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
|
||||
const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email));
|
||||
const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email));
|
||||
const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email));
|
||||
|
||||
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
|
||||
return null;
|
||||
@@ -1474,7 +1569,9 @@ export function EmailComposer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toAddresses = withInput(to, toInput);
|
||||
// Groups expand here so validation and every outgoing payload see the
|
||||
// actual member addresses.
|
||||
const toAddresses = expandRecipients(withInput(to, toInput));
|
||||
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
|
||||
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||
@@ -1603,8 +1700,8 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
const ccAddresses = withInput(cc, ccInput);
|
||||
const bccAddresses = withInput(bcc, bccInput);
|
||||
const ccAddresses = expandRecipients(withInput(cc, ccInput));
|
||||
const bccAddresses = expandRecipients(withInput(bcc, bccInput));
|
||||
|
||||
if (!canSend) {
|
||||
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
|
||||
@@ -1751,7 +1848,12 @@ export function EmailComposer({
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
};
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) return;
|
||||
if (!sendAllowed) {
|
||||
// A plugin vetoed the send (it is expected to show its own UI).
|
||||
// Leave a trace so a silent no-op send is diagnosable (#592).
|
||||
debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
|
||||
// the send: it builds raw MIME, signs/encrypts, and submits via
|
||||
@@ -2012,7 +2114,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
@@ -2042,7 +2144,7 @@ export function EmailComposer({
|
||||
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2" data-testid="composer-save-status" data-status={saveStatus}>
|
||||
<h3 className="font-semibold text-base">{t('new_message')}</h3>
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -2070,6 +2172,7 @@ export function EmailComposer({
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
data-testid="composer-send"
|
||||
className="md:hidden h-9 px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
@@ -2104,6 +2207,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
) : identities.length > 1 ? (
|
||||
<select
|
||||
data-testid="composer-from"
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||
@@ -2116,7 +2220,7 @@ export function EmailComposer({
|
||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||
: identity.email;
|
||||
return (
|
||||
<option key={identity.id} value={identity.id}>
|
||||
<option key={identity.id} value={identity.id} dir="ltr">
|
||||
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||
</option>
|
||||
);
|
||||
@@ -2128,24 +2232,24 @@ export function EmailComposer({
|
||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||
: identity.email;
|
||||
return (
|
||||
<option key={identity.id} value={identity.id}>
|
||||
<option key={identity.id} value={identity.id} dir="ltr">
|
||||
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-foreground flex-1 truncate">
|
||||
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
|
||||
{subAddressTag ? (
|
||||
<span className="font-mono">
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<bdi>
|
||||
{primaryIdentity?.name
|
||||
? `${primaryIdentity.name} <${primaryIdentity.email}>`
|
||||
: primaryIdentity?.email || ''}
|
||||
</>
|
||||
</bdi>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -2198,7 +2302,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
{/* To field */}
|
||||
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
|
||||
<RecipientChipInput
|
||||
chips={to}
|
||||
@@ -2343,6 +2447,7 @@ export function EmailComposer({
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||
<Input
|
||||
ref={subjectInputRef}
|
||||
data-testid="composer-subject"
|
||||
type="text"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
@@ -2512,24 +2617,26 @@ export function EmailComposer({
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowTemplatePicker(true)}
|
||||
title={t('use_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
title={t('save_as_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
{templatesEnabled && <>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowTemplatePicker(true)}
|
||||
title={t('use_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
title={t('save_as_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</>}
|
||||
{/* Sign/encrypt controls are contributed by crypto plugins via the
|
||||
composer-toolbar slot (rendered below). */}
|
||||
|
||||
@@ -2565,6 +2672,7 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
className="rounded-e-none border-e border-primary-foreground/20"
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
@@ -2584,7 +2692,7 @@ export function EmailComposer({
|
||||
{showSendMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2603,6 +2711,7 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
@@ -2725,7 +2834,7 @@ export function EmailComposer({
|
||||
</Button>
|
||||
<Button onClick={handleSaveDraftAndClose}>
|
||||
<Save className="w-4 h-4 me-2" />
|
||||
{t('save_draft')}
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2751,13 +2860,14 @@ export function EmailComposer({
|
||||
|
||||
const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
id: string;
|
||||
results: Array<{ name: string; email: string }>;
|
||||
results: Array<SuggestionItem>;
|
||||
selectedIndex: number;
|
||||
onSelect: (suggestion: { name: string; email: string }) => void;
|
||||
onSelect: (suggestion: SuggestionItem) => void;
|
||||
onSearchServer?: () => void;
|
||||
isSearchingServer?: boolean;
|
||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tContacts = useTranslations('contacts');
|
||||
return (
|
||||
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
@@ -2776,9 +2886,19 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
onSelect(r);
|
||||
}}
|
||||
>
|
||||
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
|
||||
{r.group ? (
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
|
||||
)}
|
||||
<span className="font-medium truncate">{r.name || r.email}</span>
|
||||
{r.name && (
|
||||
{r.group ? (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{tContacts('groups.member_count', { count: r.group.memberCount })}
|
||||
</span>
|
||||
) : r.name && (
|
||||
<span className="text-muted-foreground truncate"><{r.email}></span>
|
||||
)}
|
||||
</button>
|
||||
@@ -2844,10 +2964,10 @@ function RecipientChipInput({
|
||||
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
|
||||
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
|
||||
activeAutoField: 'to' | 'cc' | 'bcc' | null;
|
||||
autocompleteResults: Array<{ name: string; email: string }>;
|
||||
autocompleteResults: Array<SuggestionItem>;
|
||||
autoSelectedIndex: number;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
|
||||
onInsertAutocomplete: (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => void;
|
||||
canSearchServer: boolean;
|
||||
onServerSearch: () => void;
|
||||
isSearchingServer: boolean;
|
||||
@@ -2855,7 +2975,7 @@ function RecipientChipInput({
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
|
||||
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
|
||||
}) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -2864,6 +2984,9 @@ function RecipientChipInput({
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
// Gap (0..chips.length) a dragged chip would drop into; drives the insertion
|
||||
// caret and positional drop for reordering (#593). null when not dragging.
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Focus edit input when editing starts
|
||||
@@ -2880,7 +3003,9 @@ function RecipientChipInput({
|
||||
|
||||
// Format a recipient for display in a chip / context menu
|
||||
const formatChipDisplay = (r: Recipient): string =>
|
||||
r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
|
||||
r.group
|
||||
? `${r.name || 'Group'} (${r.group.members.length})`
|
||||
: r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
|
||||
|
||||
// Handle saving an edited chip
|
||||
const handleSaveEdit = (newValue: string) => {
|
||||
@@ -2900,10 +3025,10 @@ function RecipientChipInput({
|
||||
setEditingChip(null);
|
||||
return;
|
||||
}
|
||||
newChip = { name: chip.name, email: trimmedNew };
|
||||
newChip = { ...chip, email: trimmedNew };
|
||||
} else {
|
||||
// Update name, keep email. Empty name clears the display name.
|
||||
newChip = { name: trimmedNew || undefined, email: chip.email };
|
||||
newChip = { ...chip, name: trimmedNew || undefined };
|
||||
}
|
||||
|
||||
const newChips = [...chips];
|
||||
@@ -3018,27 +3143,86 @@ function RecipientChipInput({
|
||||
onAutoBlur(e, field);
|
||||
};
|
||||
|
||||
const isChipDrag = (e: React.DragEvent) =>
|
||||
e.dataTransfer.types.includes('application/x-recipient-chip');
|
||||
|
||||
// Dragging over empty container space (past the last chip / over the input)
|
||||
// targets the end of the list.
|
||||
const handleContainerDragOver = (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
|
||||
if (!isChipDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setIsDragOver(true);
|
||||
setDropIndex(chips.length);
|
||||
};
|
||||
|
||||
const handleContainerDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Dragging over a chip picks the gap before or after it based on which half
|
||||
// the pointer is in (mirrored for RTL). stopPropagation keeps the container
|
||||
// handler from overriding this finer target.
|
||||
const handleChipDragOver = (e: React.DragEvent, index: number) => {
|
||||
if (!isChipDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rtl = typeof window !== 'undefined' &&
|
||||
getComputedStyle(e.currentTarget as Element).direction === 'rtl';
|
||||
const past = rtl
|
||||
? e.clientX < rect.left + rect.width / 2
|
||||
: e.clientX > rect.left + rect.width / 2;
|
||||
setIsDragOver(true);
|
||||
setDropIndex(past ? index + 1 : index);
|
||||
};
|
||||
|
||||
// Insert the dragged chip at `target`. Same-field is a local reorder;
|
||||
// cross-field routes through onMoveChip with the destination index (#593).
|
||||
const performDrop = (e: React.DragEvent, target: number) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
setDraggingIndex(null);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
if (!raw) return;
|
||||
let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { recipient, fromField, fromIndex } = payload;
|
||||
const to = Math.max(0, Math.min(target, chips.length));
|
||||
|
||||
if (fromField === field) {
|
||||
const from = typeof fromIndex === 'number'
|
||||
? fromIndex
|
||||
: chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
|
||||
if (from < 0 || from >= chips.length) return;
|
||||
// Removing the source before `to` shifts the target left by one.
|
||||
const insertAt = to > from ? to - 1 : to;
|
||||
if (insertAt === from) return; // dropped onto its own position
|
||||
const next = [...chips];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(insertAt, 0, moved);
|
||||
onChipsChange(next);
|
||||
} else {
|
||||
onMoveChip(recipient, fromField, field, to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
if (!raw) return;
|
||||
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
|
||||
if (fromField === field) return;
|
||||
onMoveChip(recipient, fromField, field);
|
||||
performDrop(e, dropIndex ?? chips.length);
|
||||
};
|
||||
const colorStyles: Record<'success' | 'destructive' | 'warning', string> = {
|
||||
success: "bg-success/15 text-secondary-foreground hover:bg-success/30 !border-success",
|
||||
destructive: "bg-destructive/15 text-secondary-foreground hover:bg-destructive/30 !border-destructive",
|
||||
warning: "bg-warning/15 text-secondary-foreground hover:bg-warning/30 !border-warning",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -3057,30 +3241,60 @@ function RecipientChipInput({
|
||||
{chips.map((chip, i) => {
|
||||
const isEditing = editingChip?.index === i;
|
||||
const chipDisplay = formatChipDisplay(chip);
|
||||
let IconComponent = null;
|
||||
if(chip.extra?.icon){
|
||||
IconComponent = ICON_MAP[chip.extra?.icon];
|
||||
}
|
||||
const customColor = chip.extra?.color;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${chip.email}-${i}`}>
|
||||
{dropIndex === i && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="recipient-drop-caret"
|
||||
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
key={`${chip.email}-${i}`}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
|
||||
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
|
||||
// Show the address while dragging, matching the email-list drag preview.
|
||||
const dragPreview = createChipDragPreview(chip.email);
|
||||
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
requestAnimationFrame(() => dragPreview.remove());
|
||||
setDraggingIndex(i);
|
||||
}}
|
||||
onDragEnd={() => setDraggingIndex(null)}
|
||||
onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
|
||||
onDragOver={(e) => handleChipDragOver(e, i)}
|
||||
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||
isEditing
|
||||
? "bg-background ring-1 ring-ring"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing",
|
||||
: ( customColor && colorStyles[customColor]
|
||||
? `${colorStyles[customColor]} cursor-grab active:cursor-grabbing`
|
||||
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing"),
|
||||
!isEditing && draggingIndex === i && "opacity-50"
|
||||
)}
|
||||
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
customColor === "success"
|
||||
? "text-success"
|
||||
: customColor === "warning"
|
||||
? "text-warning"
|
||||
: "text-destructive"
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
@@ -3109,7 +3323,13 @@ function RecipientChipInput({
|
||||
data-bwignore="true"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[200px]">{chipDisplay}</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 max-w-[200px]"
|
||||
title={chip.group ? chip.group.members.map(m => m.email).join(', ') : undefined}
|
||||
>
|
||||
{chip.group && <Users className="w-3 h-3 shrink-0" aria-hidden />}
|
||||
<span className="truncate">{chipDisplay}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -3131,8 +3351,16 @@ function RecipientChipInput({
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{dropIndex === chips.length && chips.length > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="recipient-drop-caret"
|
||||
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{!editingChip && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -3185,7 +3413,9 @@ function RecipientChipInput({
|
||||
{formatChipDisplay(contextMenu.data.recipient)}
|
||||
</div>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||
{!contextMenu.data.recipient.group && (
|
||||
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||
)}
|
||||
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem label={tCommon('delete')} onClick={() => {
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
Archive,
|
||||
FolderInput,
|
||||
Tag,
|
||||
X,
|
||||
Check,
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
@@ -34,10 +32,12 @@ import {
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -59,12 +59,13 @@ interface EmailContextMenuProps {
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onForwardAsAttachment?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onToggleStar?: () => void;
|
||||
onTogglePinned?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
@@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get all active label/color tag IDs from email keywords
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
export function EmailContextMenu({
|
||||
email,
|
||||
position,
|
||||
@@ -127,12 +114,13 @@ export function EmailContextMenu({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMoveToMailbox,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
@@ -149,13 +137,12 @@ export function EmailContextMenu({
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tEmailViewer = useTranslations("email_viewer");
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const currentTagIds = getEmailTagIds(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
@@ -164,13 +151,6 @@ export function EmailContextMenu({
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
name: kw.label,
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
||||
}));
|
||||
|
||||
// Build mailbox tree for move-to submenu with proper hierarchy
|
||||
const moveTargetIds = new Set(
|
||||
mailboxes
|
||||
@@ -277,6 +257,12 @@ export function EmailContextMenu({
|
||||
onClick={() => handleAction(onForward!)}
|
||||
disabled={!onForward}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Paperclip}
|
||||
label={tEmailViewer("forward_as_attachment")}
|
||||
onClick={() => handleAction(onForwardAsAttachment!)}
|
||||
disabled={!onForwardAsAttachment || !email.blobId}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
@@ -295,6 +281,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("delete")}
|
||||
testId="ctx-delete"
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||
}
|
||||
@@ -306,7 +293,7 @@ export function EmailContextMenu({
|
||||
|
||||
{/* Move to submenu */}
|
||||
{moveTree.length > 0 && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||
{(() => {
|
||||
const renderNodes = (nodes: MailboxNode[]) => {
|
||||
return nodes.map((node) => {
|
||||
@@ -319,6 +306,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={nodeLabel}
|
||||
testId={`move-to:${node.id}`}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
@@ -368,37 +356,13 @@ export function EmailContextMenu({
|
||||
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="flex-1">{option.name}</span>
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={X}
|
||||
label={t("remove_color")}
|
||||
onClick={() => handleAction(() => onSetColorTag?.(null))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
@@ -410,6 +374,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
showBatchActions
|
||||
@@ -429,6 +394,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={isUnread ? MailOpen : Mail}
|
||||
label={isUnread ? t("mark_read") : t("mark_unread")}
|
||||
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
|
||||
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||
// the spam quick-action flips to "not spam".
|
||||
@@ -76,7 +76,7 @@ export function EmailHoverActions({
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
@@ -112,7 +112,7 @@ export function EmailHoverActions({
|
||||
onArchive?.();
|
||||
break;
|
||||
case "tag":
|
||||
onSetColorTag?.(null);
|
||||
onSetTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
if (isInJunk) onUndoSpam?.();
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback } from "react";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
|
||||
email
|
||||
);
|
||||
}, [onContextMenu, email]),
|
||||
isMobile
|
||||
);
|
||||
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
// Shift-click extends the selection from the anchor to here, like
|
||||
// shift-clicking the row (the checkbox stops propagation, so the
|
||||
// row's shift handler never runs — replicate it here).
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
toggleEmailSelection(email.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
// Apply color tag as background, with selected and unread states
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-selection"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||
// Add visual feedback for checked state
|
||||
isChecked && "ring-2 ring-primary/20 bg-selection/60",
|
||||
// Drag state visual feedback
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
// Long press visual feedback
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleEmailSelection(email.id);
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
if (selectedEmailIds.size > 0) clearSelection();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||
!isFocusedMailLayout && 'mt-2',
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
isChecked && "text-primary"
|
||||
)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
<SelectableAvatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
checked={isChecked}
|
||||
onToggle={() => toggleEmailSelection(email.id)}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
||||
Important
|
||||
</span>
|
||||
)}
|
||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
||||
{isAnswered && !isForwarded && (
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{isForwarded && !isAnswered && (
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
|
||||
@@ -33,12 +34,13 @@ interface EmailListProps {
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
onForwardAsAttachment?: (email: Email) => void;
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onTogglePinned?: (email: Email) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
@@ -63,12 +65,13 @@ export function EmailList({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
@@ -130,10 +133,20 @@ export function EmailList({
|
||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
/**
|
||||
* The row the menu was opened on, as the list currently has it. The menu holds
|
||||
* the message it was handed when it opened, but tags can be applied from
|
||||
* inside it without dismissing it, so what it draws has to keep up.
|
||||
*/
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// One tag treatment for the whole list, measured from the scroll container.
|
||||
const tagDisplay = useMeasuredTagDisplay(parentRef);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -191,6 +204,22 @@ export function EmailList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUndoSpam = async () => {
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const emailIds = Array.from(selectedEmailIds);
|
||||
await batchUndoSpam(client, emailIds);
|
||||
const { toast } = await import('sonner');
|
||||
toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
|
||||
} catch {
|
||||
const { toast } = await import('sonner');
|
||||
toast.error(t('../email_viewer.spam.error_not_spam'));
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!client || isProcessing) return;
|
||||
|
||||
@@ -314,6 +343,7 @@ export function EmailList({
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<TagDisplayContext.Provider value={tagDisplay}>
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
{/* Batch Actions Toolbar */}
|
||||
<div
|
||||
@@ -357,6 +387,22 @@ export function EmailList({
|
||||
<Mail className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
{effectiveMailboxRole === 'junk' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBatchUndoSpam}
|
||||
title={t('../context_menu.not_spam')}
|
||||
disabled={isProcessing}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -509,7 +555,7 @@ export function EmailList({
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
/>
|
||||
@@ -536,9 +582,9 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.data && (
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
email={contextMenu.data}
|
||||
email={contextMenuEmail}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
@@ -546,24 +592,25 @@ export function EmailList({
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
currentMailboxRole={effectiveMailboxRole}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
||||
onReply={() => onReply?.(contextMenuEmail!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||
onForward={() => onForward?.(contextMenuEmail!)}
|
||||
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenuEmail!)}
|
||||
onArchive={() => onArchive?.(contextMenuEmail!)}
|
||||
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
@@ -610,5 +657,6 @@ export function EmailList({
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</TagDisplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+218
-209
@@ -5,12 +5,18 @@ import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
@@ -18,6 +24,7 @@ import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
Archive,
|
||||
Star,
|
||||
@@ -72,7 +79,7 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -108,11 +115,12 @@ interface EmailViewerProps {
|
||||
onReply?: (draftText?: string) => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onForwardAsAttachment?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
|
||||
onQuickReply?: (body: string) => Promise<void>;
|
||||
onMarkAsSpam?: () => void;
|
||||
@@ -197,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
return 'Attachment';
|
||||
};
|
||||
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
// Helper function to format recipients with contextual display
|
||||
const _formatRecipients = (
|
||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||
@@ -560,6 +555,8 @@ export function ContactSidebarPanel({
|
||||
interface DraggableAttachmentChipProps {
|
||||
attachment: EffectiveAttachment;
|
||||
client: IJMAPClient | null;
|
||||
/** Owner accountId for the blob when it lives in a delegated/shared account. */
|
||||
accountId?: string;
|
||||
enabled: boolean;
|
||||
downloadName?: string;
|
||||
children: (dragProps: {
|
||||
@@ -570,14 +567,14 @@ interface DraggableAttachmentChipProps {
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
const source = useMemo<AttachmentDragSource>(() => ({
|
||||
name: downloadName || attachment.name || 'download',
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
getBlobUrl: async () => {
|
||||
if (attachment.blobId && client) {
|
||||
try {
|
||||
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
|
||||
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -595,7 +592,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}), [attachment, client, downloadName]);
|
||||
}), [attachment, client, accountId, downloadName]);
|
||||
const drag = useAttachmentDrag(source, enabled);
|
||||
return <>{children(drag)}</>;
|
||||
}
|
||||
@@ -618,11 +615,12 @@ export function EmailViewer({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onDownloadAttachment,
|
||||
onQuickReply,
|
||||
onMarkAsSpam,
|
||||
@@ -652,6 +650,7 @@ export function EmailViewer({
|
||||
const tDemoWelcome = useTranslations('demo_welcome');
|
||||
const tWelcome = useTranslations('welcome');
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
@@ -660,6 +659,7 @@ export function EmailViewer({
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -702,18 +702,24 @@ export function EmailViewer({
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Color options for email tags (from user-defined keyword settings)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
name: kw.label,
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
||||
}));
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
// Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
|
||||
// downloads) are account-scoped. In the unified / All-Mail view the open
|
||||
// message may belong to another login (route to its client) or a delegated
|
||||
// shared account (same client, owner accountId in the URL). Resolve both from
|
||||
// the message's source so cross-account blob fetches don't 404 against the
|
||||
// active account.
|
||||
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||
const blobClient = useMemo(() => {
|
||||
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
|
||||
}, [isUnifiedView, email?.sourceClientAccountId, client]);
|
||||
const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
|
||||
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
|
||||
// client, handing a mailto: URL to the OS mail handler goes nowhere for
|
||||
@@ -800,8 +806,13 @@ export function EmailViewer({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
const currentColors = getCurrentColors(email?.keywords);
|
||||
const currentColor = currentColors[0] ?? null;
|
||||
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||
const sortedTagIds = sortTagIds(currentTagIds);
|
||||
// The header spans the reading pane, so it measures its own width rather than
|
||||
// inheriting the message list's answer.
|
||||
const headerTagsRef = useRef<HTMLDivElement>(null);
|
||||
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
|
||||
const currentColor = currentTagIds[0] ?? null;
|
||||
|
||||
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
|
||||
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
||||
@@ -999,7 +1010,7 @@ export function EmailViewer({
|
||||
showToolbarLabels,
|
||||
isLoading,
|
||||
moveTree.length,
|
||||
colorOptions.length,
|
||||
emailKeywords.length,
|
||||
currentColor,
|
||||
isInJunkFolder,
|
||||
isTablet,
|
||||
@@ -1175,6 +1186,7 @@ export function EmailViewer({
|
||||
id: email.id,
|
||||
contentType,
|
||||
bodyStructure: email.bodyStructure,
|
||||
bodyValues: email.bodyValues,
|
||||
attachments: email.attachments,
|
||||
blobId: email.blobId,
|
||||
from: email.from,
|
||||
@@ -1250,7 +1262,7 @@ export function EmailViewer({
|
||||
async function processTnef() {
|
||||
try {
|
||||
debug.time('TNEF fetch blob', 'email');
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||
const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
|
||||
debug.timeEnd('TNEF fetch blob', 'email');
|
||||
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
|
||||
@@ -1303,7 +1315,7 @@ export function EmailViewer({
|
||||
processTnef();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [email, client]);
|
||||
}, [email, client, blobClient, blobAccountId]);
|
||||
|
||||
// Embedded message/rfc822 unwrapping
|
||||
// When Outlook forwards an email as an attachment, the outer email body is
|
||||
@@ -1340,7 +1352,7 @@ export function EmailViewer({
|
||||
|
||||
async function unwrapEmbedded() {
|
||||
try {
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
if (blobBytes.byteLength === 0) {
|
||||
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||
@@ -1380,7 +1392,7 @@ export function EmailViewer({
|
||||
unwrapEmbedded();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [email, client]);
|
||||
}, [email, client, blobClient, blobAccountId]);
|
||||
|
||||
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||
useEffect(() => {
|
||||
@@ -1426,7 +1438,7 @@ export function EmailViewer({
|
||||
await Promise.all(cidAttachments.map(async (att) => {
|
||||
const cidValue = att.cid!.replace(/^<|>$/g, '');
|
||||
try {
|
||||
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
|
||||
const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
|
||||
if (!cancelled) {
|
||||
urls[cidValue] = objectUrl;
|
||||
objectUrls.push(objectUrl);
|
||||
@@ -1448,7 +1460,7 @@ export function EmailViewer({
|
||||
cancelled = true;
|
||||
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
|
||||
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
|
||||
|
||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||
if (pluginRenderedAttachments.length > 0) {
|
||||
@@ -1647,10 +1659,8 @@ export function EmailViewer({
|
||||
}
|
||||
}
|
||||
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
// http(s) links open in a new tab; other schemes keep their default.
|
||||
applyNewTabToAnchor(node);
|
||||
|
||||
// No dark mode color transforms - emails render true-to-life in iframe
|
||||
});
|
||||
@@ -1688,7 +1698,11 @@ export function EmailViewer({
|
||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||
|
||||
return {
|
||||
html: plainTextToSafeHtml(textContent),
|
||||
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
|
||||
html: collapsePlainTextQuotes(plainTextToSafeHtml(textContent), {
|
||||
show: t('show_quoted_text'),
|
||||
hide: t('hide_quoted_text'),
|
||||
}),
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
externalBlocked: false,
|
||||
@@ -1728,6 +1742,11 @@ export function EmailViewer({
|
||||
|
||||
// Override email content with S/MIME decrypted content when available
|
||||
const effectiveEmailContent = useMemo(() => {
|
||||
const plainToHtml = (text: string) =>
|
||||
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
|
||||
show: t('show_quoted_text'),
|
||||
hide: t('hide_quoted_text'),
|
||||
});
|
||||
if (pluginRenderedHtml) {
|
||||
const htmlWithCidUrls = pluginRenderedHtml.replace(
|
||||
/\bcid:([^"'\s)]+)/gi,
|
||||
@@ -1739,7 +1758,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
|
||||
}
|
||||
if (pluginRenderedText) {
|
||||
return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
@@ -1747,7 +1766,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
|
||||
}
|
||||
if (tnefText) {
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
return { html: plainToHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
@@ -1755,10 +1774,10 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
|
||||
|
||||
const resolveAttachmentName = useCallback(
|
||||
(attachment: EffectiveAttachment) => {
|
||||
@@ -1926,8 +1945,8 @@ export function EmailViewer({
|
||||
for (const attachment of effectiveAttachments) {
|
||||
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
|
||||
try {
|
||||
if (attachment.blobId && client) {
|
||||
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
|
||||
if (attachment.blobId && blobClient) {
|
||||
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
|
||||
zip.file(entryName, blob);
|
||||
added++;
|
||||
} else if (attachment.tnefData) {
|
||||
@@ -1959,7 +1978,7 @@ export function EmailViewer({
|
||||
} finally {
|
||||
setIsDownloadingAll(false);
|
||||
}
|
||||
}, [isDownloadingAll, effectiveAttachments, client, email]);
|
||||
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
|
||||
|
||||
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
|
||||
const downloadAllButton = effectiveAttachments.length > 1 ? (
|
||||
@@ -2001,8 +2020,8 @@ export function EmailViewer({
|
||||
await Promise.all(imageAttachments.map(async (att) => {
|
||||
let url: string | undefined;
|
||||
try {
|
||||
if (att.blobId && client) {
|
||||
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
|
||||
if (att.blobId && blobClient) {
|
||||
url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
|
||||
} else if (att.decryptedAttachment) {
|
||||
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
|
||||
if (!bytes || bytes.byteLength === 0) return;
|
||||
@@ -2033,7 +2052,7 @@ export function EmailViewer({
|
||||
cancelled = true;
|
||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
|
||||
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
|
||||
|
||||
// Iframe for rendering HTML emails true-to-life
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -2103,9 +2122,21 @@ export function EmailViewer({
|
||||
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
||||
// @page margins (print-only), so they need a fallback body padding too.
|
||||
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
|
||||
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
|
||||
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
|
||||
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
|
||||
// background canvas (a width:100% element carrying a background colour) --
|
||||
// the one case where the gutter shows as a frame around the email's own
|
||||
// background. A <style> tag alone is too weak a signal: plenty of
|
||||
// transactional mails ship one for web fonts yet have no gutter of their
|
||||
// own, and zeroing the padding glues their content to the corner.
|
||||
const emailHtml = effectiveEmailContent.html;
|
||||
const hasFullBleedCanvas =
|
||||
/<(?:table|div|body)\b[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)/i.test(emailHtml) ||
|
||||
/<(?:table|div|body)\b[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)/i.test(emailHtml);
|
||||
const autoDropsGutter = effectiveEmailContent.hasStyleTag && !isWordHtml && hasFullBleedCanvas;
|
||||
const dropGutter =
|
||||
messageSpacing === 'edge' || (messageSpacing === 'auto' && autoDropsGutter);
|
||||
const bodyPadding = dropGutter ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = dropGutter ? '0' : '0.75rem';
|
||||
|
||||
// Word emails rely on empty <p class=MsoNormal> </p> spacers for vertical
|
||||
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||
@@ -2166,7 +2197,7 @@ export function EmailViewer({
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
|
||||
|
||||
// Unblocking external content is handled by rebuilding the iframe srcDoc:
|
||||
// toggling allowExternalContent (both "Load images" and "Trust sender" set
|
||||
@@ -2190,8 +2221,20 @@ export function EmailViewer({
|
||||
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||
// it doesn't flash in below a still-resizing iframe.
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
// Tracks which parsed document we've already wired up, so setup runs exactly
|
||||
// once per srcDoc even though both the readiness poll below and the iframe
|
||||
// 'load' event can trigger it.
|
||||
const initializedDocRef = useRef<Document | null>(null);
|
||||
// The document present at the instant srcDoc changed - i.e. the one about to
|
||||
// be torn down. contentDocument keeps pointing at it until the browser swaps
|
||||
// the new srcDoc in, so the poll skips it to avoid wiring up stale content.
|
||||
const staleDocRef = useRef<Document | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
setIframeReady(false);
|
||||
initializedDocRef.current = null;
|
||||
// Runs during commit, before the browser processes the new srcDoc, so
|
||||
// contentDocument here is still the outgoing document.
|
||||
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
|
||||
}, [emailIframeSrcDoc]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
@@ -2199,7 +2242,21 @@ export function EmailViewer({
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc?.body) {
|
||||
// Ignore the outgoing document, a transient about:blank (a fresh srcDoc
|
||||
// document reports URL 'about:srcdoc'), and anything that hasn't finished
|
||||
// parsing yet; run the setup below at most once per document.
|
||||
if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return;
|
||||
if (initializedDocRef.current === doc) return;
|
||||
initializedDocRef.current = doc;
|
||||
{
|
||||
// Collapse the quoted original of a reply behind a "•••" toggle
|
||||
// (#480). Before the height wiring, so the initial measurement
|
||||
// already reflects the collapsed body.
|
||||
setupQuoteCollapse(doc, {
|
||||
show: t('show_quoted_text'),
|
||||
hide: t('hide_quoted_text'),
|
||||
});
|
||||
|
||||
// Auto-resize iframe to fit content
|
||||
// Measure max(documentElement, body): a height:100% wrapper can leave
|
||||
// documentElement.scrollHeight short while the real content lives in body.
|
||||
@@ -2247,11 +2304,9 @@ export function EmailViewer({
|
||||
}
|
||||
});
|
||||
|
||||
// Make links open in new tab
|
||||
doc.querySelectorAll('a').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
// Second pass over the rendered iframe DOM (the hook above only sees
|
||||
// DOMPurify's output); http(s) → new tab, other schemes left in place.
|
||||
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
|
||||
|
||||
// Plugin intercept: let plugins cancel or rewrite external links inside
|
||||
// the email body before navigation happens. Bound on the iframe doc so
|
||||
@@ -2373,7 +2428,27 @@ export function EmailViewer({
|
||||
} catch {
|
||||
// Cross-origin restrictions - iframe will still display content
|
||||
}
|
||||
}, [isDark, emailHasNativeDarkMode, email?.id]);
|
||||
}, [isDark, emailHasNativeDarkMode, email?.id, t]);
|
||||
|
||||
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
|
||||
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
|
||||
// so a single unreachable remote image (server accepts the TCP connection but
|
||||
// never responds) stalls it for the browser's ~60s timeout - freezing the body
|
||||
// at its placeholder height that entire time. The parsed DOM we need for
|
||||
// height, links and dark-mode is ready long before images resolve. Poll the
|
||||
// fresh document's readyState because a sandbox without allow-scripts can't
|
||||
// postMessage a DOMContentLoaded signal out, and the onLoad handler is
|
||||
// idempotent per document so it stays a harmless backstop.
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
const readyPoll = window.setInterval(() => {
|
||||
handleIframeLoad();
|
||||
if (initializedDocRef.current) window.clearInterval(readyPoll);
|
||||
}, 50);
|
||||
// Safety stop: the 'load' backstop covers anything the poll somehow misses.
|
||||
const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000);
|
||||
return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); };
|
||||
}, [emailIframeSrcDoc, handleIframeLoad]);
|
||||
|
||||
// Export email as .eml file
|
||||
const handleExportEmail = async () => {
|
||||
@@ -2781,6 +2856,7 @@ export function EmailViewer({
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEditDraft()}
|
||||
data-testid="edit-draft"
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.edit_draft')}
|
||||
>
|
||||
@@ -2913,64 +2989,18 @@ export function EmailViewer({
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||
currentColors.length > 0 && "bg-muted/50"
|
||||
)}
|
||||
title={t('set_color')}
|
||||
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||
title={t('set_tag')}
|
||||
>
|
||||
{currentColors.length > 0 ? (
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
)}
|
||||
<Tag className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||
</button>
|
||||
{tagMenuOpen && (
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3162,7 +3192,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
onMouseLeave={() => setMoreMenuSub(null)}
|
||||
@@ -3176,36 +3206,11 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3259,6 +3264,16 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
{/* Forward as attachment */}
|
||||
{onForwardAsAttachment && email?.blobId && (
|
||||
<button
|
||||
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{t('forward_as_attachment')}
|
||||
</button>
|
||||
)}
|
||||
{/* Export email */}
|
||||
<button
|
||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
@@ -3339,19 +3354,21 @@ export function EmailViewer({
|
||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
</button>
|
||||
{/* Tag (opens sub-view) */}
|
||||
{colorOptions.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<button
|
||||
onClick={() => setMoreMenuSub('tag')}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Tag className="w-5 h-5" />
|
||||
<span className="flex-1">{t('tag')}</span>
|
||||
{currentColors.length > 0 && (
|
||||
{currentTagIds.length > 0 && (
|
||||
<div className="flex -space-x-1 me-1">
|
||||
{currentColors.slice(0, 3).map((c) => {
|
||||
const opt = colorOptions.find((o) => o.value === c);
|
||||
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||
})}
|
||||
{sortedTagIds.slice(0, 3).map((tagId) => (
|
||||
<span
|
||||
key={tagId}
|
||||
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -3381,6 +3398,15 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
{onForwardAsAttachment && email?.blobId && (
|
||||
<button
|
||||
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
{t('forward_as_attachment')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
@@ -3438,35 +3464,12 @@ export function EmailViewer({
|
||||
};
|
||||
return renderMobileNodes(moveTree);
|
||||
})()}
|
||||
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||
<>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<TagPicker
|
||||
touch
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3524,24 +3527,24 @@ export function EmailViewer({
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
{/* Color tag dots */}
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||
return (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||
{t('important')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sortedTagIds.length > 0 && (
|
||||
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
|
||||
{sortedTagIds.map((tagId) => (
|
||||
<TagBadge
|
||||
key={tagId}
|
||||
tagId={tagId}
|
||||
variant={headerTagVariant}
|
||||
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||
@@ -3692,7 +3695,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -3703,6 +3706,8 @@ export function EmailViewer({
|
||||
)}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name}
|
||||
draggable={dragProps.draggable}
|
||||
onPointerEnter={dragProps.onPointerEnter}
|
||||
onDragStart={dragProps.onDragStart}
|
||||
@@ -3730,7 +3735,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
|
||||
)}>
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -3767,13 +3772,13 @@ export function EmailViewer({
|
||||
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllBesideAttachments(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
{effectiveAttachments.slice(2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -3791,7 +3796,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -4466,7 +4471,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4477,6 +4482,8 @@ export function EmailViewer({
|
||||
)}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name}
|
||||
draggable={dragProps.draggable}
|
||||
onPointerEnter={dragProps.onPointerEnter}
|
||||
onDragStart={dragProps.onDragStart}
|
||||
@@ -4509,7 +4516,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
|
||||
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-r-md rounded-l-none",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
|
||||
)}>
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -4546,13 +4553,13 @@ export function EmailViewer({
|
||||
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
|
||||
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -4570,7 +4577,7 @@ export function EmailViewer({
|
||||
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -4610,7 +4617,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4621,6 +4628,8 @@ export function EmailViewer({
|
||||
)}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name}
|
||||
draggable={dragProps.draggable}
|
||||
onPointerEnter={dragProps.onPointerEnter}
|
||||
onDragStart={dragProps.onDragStart}
|
||||
@@ -4648,7 +4657,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
|
||||
)}>
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -4684,13 +4693,13 @@ export function EmailViewer({
|
||||
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
{effectiveAttachments.slice(2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -4708,7 +4717,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
// Native tab strip for plugin-registered message-list category tabs
|
||||
// (Gmail-style Primary / Promotions / Social / Updates). Renders above the
|
||||
// email list; the active tab's resolved JMAP filter is ANDed into the
|
||||
// mailbox query by email-store.fetchEmails. Plugins only contribute tab
|
||||
// DEFINITIONS (stores/message-list-tabs-store.ts) - no plugin iframe here.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MessageListTabs() {
|
||||
const tabs = useMessageListTabsStore((s) => s.tabs);
|
||||
const mailboxRoles = useMessageListTabsStore((s) => s.mailboxRoles);
|
||||
const activeTabId = useMessageListTabsStore((s) => s.activeTabId);
|
||||
const tabCounts = useMessageListTabsStore((s) => s.tabCounts);
|
||||
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const selectedKeyword = useEmailStore((s) => s.selectedKeyword);
|
||||
const searchQuery = useEmailStore((s) => s.searchQuery);
|
||||
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const mailbox = mailboxes.find((mb) => mb.id === selectedMailbox);
|
||||
const role = mailbox?.role?.toLowerCase() ?? null;
|
||||
// Tabs only make sense on a plain mailbox view: tag views, searches and
|
||||
// unified fan-outs bypass the category filter in fetchEmails, so the strip
|
||||
// must disappear rather than lie about what's being shown.
|
||||
const visible =
|
||||
tabs.length > 0 &&
|
||||
!!role &&
|
||||
mailboxRoles.includes(role) &&
|
||||
!selectedKeyword &&
|
||||
!searchQuery &&
|
||||
!isUnifiedView;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || !mailbox) return;
|
||||
const jmapMailboxId = mailbox.originalId || mailbox.id;
|
||||
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
void useMessageListTabsStore.getState().refreshCounts(client, jmapMailboxId, accountId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, client, selectedMailbox, tabs]);
|
||||
|
||||
// A plugin registering (or clearing) tabs after the list was fetched leaves
|
||||
// the visible list out of sync with the strip's active-tab filter - refetch
|
||||
// exactly when the merged tab set changes, never on ordinary view switches
|
||||
// (those already refetch through their own flows).
|
||||
const prevTabsRef = useRef(tabs);
|
||||
useEffect(() => {
|
||||
if (prevTabsRef.current === tabs) return;
|
||||
prevTabsRef.current = tabs;
|
||||
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||
}, [tabs, client]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleSelect = (tabId: string) => {
|
||||
if (tabId === activeTabId) return;
|
||||
useMessageListTabsStore.getState().setActiveTab(tabId, selectedMailbox);
|
||||
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-stretch gap-1 px-2 border-b border-border overflow-x-auto shrink-0"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
role="tablist"
|
||||
aria-label="Inbox categories"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
? (lucideIcons[tab.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: undefined;
|
||||
const unread = tabCounts[tab.id] ?? 0;
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleSelect(tab.id)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-1.5 px-3.5 py-2.5 text-sm whitespace-nowrap select-none',
|
||||
'border-b-2 -mb-px rounded-t-md transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'border-primary text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
)}
|
||||
style={isActive && tab.color ? { borderBottomColor: tab.color } : undefined}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn('h-4 w-4 flex-shrink-0', !isActive && 'opacity-70')}
|
||||
style={isActive && tab.color ? { color: tab.color } : undefined}
|
||||
/>
|
||||
)}
|
||||
<span>{tab.label}</span>
|
||||
{tab.showUnreadBadge !== false && unread > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold tabular-nums',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
title={`${unread} unread`}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
// HTML, so parseHTML can recognise it on the way back in.
|
||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||
|
||||
// Reusable style for the quote bar when quoting email text (like in a reply).
|
||||
const QUOTE_BAR_STYLE =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
|
||||
/**
|
||||
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||
dom.className = "quoted-html-island";
|
||||
dom.style.cssText =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
dom.style.cssText = QUOTE_BAR_STYLE;
|
||||
|
||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
|
||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayLabel || name || email}
|
||||
<bdi>{displayLabel || name || email}</bdi>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
|
||||
@@ -21,6 +21,7 @@ import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-ht
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Table as TableIcon,
|
||||
Baseline,
|
||||
Trash2,
|
||||
Rows3,
|
||||
Columns3,
|
||||
@@ -143,7 +145,16 @@ function ToolbarSeparator() {
|
||||
const TABLE_PICKER_ROWS = 6;
|
||||
const TABLE_PICKER_COLS = 8;
|
||||
|
||||
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
|
||||
// round-trips; the TextStyle/Color extensions are already registered to
|
||||
// preserve pasted colours - this palette just adds a UI to set them.
|
||||
const TEXT_COLORS = [
|
||||
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
|
||||
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
|
||||
];
|
||||
|
||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||
const t = useTranslations("email_composer.toolbar");
|
||||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||
return (
|
||||
<div>
|
||||
@@ -171,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
||||
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
|
||||
{hover ? `${hover.r + 1} × ${hover.c + 1}` : t("pick_size")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -334,8 +345,22 @@ export function RichTextEditor({
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const tToolbar = useTranslations("email_composer.toolbar");
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
const colorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorMenuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
|
||||
setColorMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [colorMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableMenuOpen) return;
|
||||
@@ -361,45 +386,88 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="Bold"
|
||||
title={tToolbar("bold")}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="Italic"
|
||||
title={tToolbar("italic")}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("underline")}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
title="Underline"
|
||||
title={tToolbar("underline")}
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="Strikethrough"
|
||||
title={tToolbar("strikethrough")}
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<div ref={colorWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
active={!!editor.getAttributes("textStyle").color}
|
||||
onClick={() => setColorMenuOpen((v) => !v)}
|
||||
title={tToolbar("text_color")}
|
||||
>
|
||||
{/* The icon itself previews the active colour - no layout shift. */}
|
||||
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||
</ToolbarButton>
|
||||
{colorMenuOpen && (
|
||||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
|
||||
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" /> {tToolbar("remove_color")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
title="Heading 1"
|
||||
title={tToolbar("heading_1")}
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
title="Heading 2"
|
||||
title={tToolbar("heading_2")}
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -409,28 +477,28 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bulletList")}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
title="Bullet List"
|
||||
title={tToolbar("bullet_list")}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("orderedList")}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
title="Ordered List"
|
||||
title={tToolbar("ordered_list")}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("blockquote")}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
title="Quote"
|
||||
title={tToolbar("quote")}
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("codeBlock")}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
title="Code Block"
|
||||
title={tToolbar("code_block")}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -440,21 +508,21 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "left" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
title="Align Left"
|
||||
title={tToolbar("align_left")}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "center" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||
title="Align Center"
|
||||
title={tToolbar("align_center")}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "right" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
title="Align Right"
|
||||
title={tToolbar("align_right")}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -469,7 +537,7 @@ export function RichTextEditor({
|
||||
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||
}}
|
||||
title="Text direction (RTL/LTR)"
|
||||
title={tToolbar("text_direction")}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -480,7 +548,7 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("link")}
|
||||
onClick={addLink}
|
||||
title="Link"
|
||||
title={tToolbar("link")}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -489,12 +557,12 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("table")}
|
||||
onClick={() => setTableMenuOpen((v) => !v)}
|
||||
title="Table"
|
||||
title={tToolbar("table")}
|
||||
>
|
||||
<TableIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{tableMenuOpen && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||
{editor.isActive("table") ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
@@ -502,28 +570,28 @@ export function RichTextEditor({
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Add row above
|
||||
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_above")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Add row below
|
||||
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_below")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Columns3 className="w-4 h-4" /> Add column before
|
||||
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_before")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Columns3 className="w-4 h-4" /> Add column after
|
||||
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_after")}
|
||||
</button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -531,21 +599,21 @@ export function RichTextEditor({
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete row
|
||||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_row")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete column
|
||||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_column")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||
<Rows3 className="w-4 h-4" /> {tToolbar("toggle_header_row")}
|
||||
</button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -553,7 +621,7 @@ export function RichTextEditor({
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
|
||||
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete table
|
||||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_table")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -572,7 +640,7 @@ export function RichTextEditor({
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
title={tToolbar("clear_formatting")}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -582,14 +650,14 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo"
|
||||
title={tToolbar("undo")}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo"
|
||||
title={tToolbar("redo")}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||
|
||||
/**
|
||||
* Force every link in the rendered signature to open in a new tab.
|
||||
*
|
||||
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
|
||||
* what serializeEditorContent emits into the sent message, and the recipient's
|
||||
* copy should stay exactly as the user wrote it. Without this the composer's
|
||||
* signature is a set of live, target-less anchors in the main document (the
|
||||
* message body gets a sandboxed iframe; this does not), so one stray click
|
||||
* navigates the whole app away and takes the unsent draft with it.
|
||||
*/
|
||||
function forceLinksToNewTab(root: HTMLElement): void {
|
||||
root.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
|
||||
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||
dom.className = "signature-block-island";
|
||||
|
||||
|
||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||
// would otherwise cascade INTO the signature and destroy its layout -
|
||||
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
|
||||
const inner = document.createElement("div");
|
||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||
inner.contentEditable = "false";
|
||||
inner.innerHTML = node.attrs.html || "";
|
||||
// Track what we were given, not what's in the DOM: forceLinksToNewTab
|
||||
// rewrites the markup, so inner.innerHTML no longer round-trips against
|
||||
// attrs.html and comparing the two would rewrite on every transaction.
|
||||
let appliedHtml = node.attrs.html || "";
|
||||
inner.innerHTML = appliedHtml;
|
||||
forceLinksToNewTab(inner);
|
||||
shadow.appendChild(inner);
|
||||
|
||||
return {
|
||||
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
|
||||
stopEvent: () => false,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
||||
inner.innerHTML = updatedNode.attrs.html || "";
|
||||
const nextHtml = updatedNode.attrs.html || "";
|
||||
if (nextHtml !== appliedHtml) {
|
||||
appliedHtml = nextHtml;
|
||||
inner.innerHTML = nextHtml;
|
||||
forceLinksToNewTab(inner);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
* How much room the surface has for a tag.
|
||||
* - `badge` names the tag; `dot` only identifies it by colour.
|
||||
*/
|
||||
export type TagBadgeVariant = "badge" | "dot";
|
||||
|
||||
/**
|
||||
* The lozenge shape, shared so anything standing next to a tag lines up with
|
||||
* it rather than approximating its padding and text size.
|
||||
*/
|
||||
export const TAG_LOZENGE_CLASS =
|
||||
"inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium";
|
||||
|
||||
/**
|
||||
* The row a group of tags sits in. Using it for neighbouring lozenges too keeps
|
||||
* the spacing between them the same as the spacing within them - a wider gap on
|
||||
* one side is what makes a neighbour look indented.
|
||||
*/
|
||||
export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
|
||||
|
||||
/**
|
||||
* A tag, drawn the one way tags are drawn.
|
||||
*
|
||||
* The lozenge carries the colour in its border and text rather than pairing a
|
||||
* swatch with plain text: the name is the tag, and the colour is how you pick
|
||||
* it out of a row at a glance. That also matches every other coloured pill in
|
||||
* the app, all of which set a text colour alongside the background.
|
||||
*
|
||||
* A deep name shortens to fit its own box (`Work/../Acme`) before the browser
|
||||
* clips it, so the outermost and innermost levels survive.
|
||||
*/
|
||||
export function TagBadge({
|
||||
tagId,
|
||||
variant,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tagId: string;
|
||||
variant: TagBadgeVariant;
|
||||
/**
|
||||
* Takes the tag off the message. Only the named form offers it - a dot is the
|
||||
* size of the control it would have to hold.
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
const name = tagName(tagId);
|
||||
|
||||
if (variant === "dot") {
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[12rem] border",
|
||||
color.fill,
|
||||
color.border,
|
||||
color.text,
|
||||
className,
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<span ref={labelRef} className="min-w-0 truncate">
|
||||
{shortenedName}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title={t("remove_tag")}
|
||||
aria-label={t("remove_tag")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
/** Below this many tags a filter box costs more room than it saves. */
|
||||
const SEARCH_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* The list of tags to apply to a message.
|
||||
*
|
||||
* Shared by all four places one appears - the toolbar popover, the overflow
|
||||
* flyout, the mobile sheet and the context menu - because they had drifted into
|
||||
* four different dot sizes, check alignments and separators, and only one of
|
||||
* them capped its height.
|
||||
*
|
||||
* Nested tags are drawn as a tree rather than repeating the parent's name on
|
||||
* every child. Filtering flattens it: with a query the hierarchy is noise, and
|
||||
* the full path is what gets matched.
|
||||
*/
|
||||
export function TagPicker({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
touch = false,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onToggle: (tagId: string) => void;
|
||||
/** Larger hit areas for the mobile sheet. */
|
||||
touch?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const nestedTags = useSettingsStore((state) => state.nestedTags);
|
||||
const { tagName, tagColor } = useKeywordFormat();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
|
||||
/**
|
||||
* Tags on the message this client has no definition for - set from another
|
||||
* client, or outliving the tag they were made with. Listing them is the only
|
||||
* way to take one off, and they leave the list as they are deselected because
|
||||
* nothing but the message itself records that they exist.
|
||||
*/
|
||||
const unknownIds = useMemo(
|
||||
() =>
|
||||
selectedIds
|
||||
.filter((id) => !keywords.some((keyword) => keyword.id === id))
|
||||
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
|
||||
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
|
||||
[selectedIds, keywords, tagName],
|
||||
);
|
||||
|
||||
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
|
||||
|
||||
const matches = useMemo(
|
||||
() =>
|
||||
trimmedQuery
|
||||
? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
|
||||
tagName(id).toLowerCase().includes(trimmedQuery),
|
||||
)
|
||||
: [],
|
||||
[keywords, unknownIds, trimmedQuery, tagName],
|
||||
);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))),
|
||||
[keywords, nestedTags],
|
||||
);
|
||||
|
||||
const rowClass = cn(
|
||||
"w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm",
|
||||
);
|
||||
const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3";
|
||||
const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5";
|
||||
|
||||
const renderRow = (id: string, label: string) => {
|
||||
const isActive = selectedIds.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isActive}
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(rowClass, isActive && "bg-accent font-medium")}
|
||||
title={tagName(id)}
|
||||
>
|
||||
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
|
||||
<span className="flex-1 min-w-0 truncate">{label}</span>
|
||||
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (nodes: KeywordNode[]) =>
|
||||
nodes.map((node) => (
|
||||
<div key={node.id}>
|
||||
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
|
||||
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("tag_filter_placeholder")}
|
||||
aria-label={t("tag_filter_placeholder")}
|
||||
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
|
||||
{trimmedQuery ? (
|
||||
matches.length > 0 ? (
|
||||
matches.map((id) => renderRow(id, tagName(id)))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderBranch(tree)}
|
||||
{unknownIds.length > 0 && (
|
||||
<>
|
||||
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
|
||||
{unknownIds.map((id) => renderRow(id, tagName(id)))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,12 @@ declare module "@tiptap/core" {
|
||||
|
||||
/**
|
||||
* Adds a `dir` attribute to block nodes so the composer can mark individual
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
|
||||
*
|
||||
* The default is `"auto"`: each block detects its own direction from its first
|
||||
* strong character, so a paragraph typed in English renders LTR and one typed
|
||||
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
|
||||
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
|
||||
* attribute round-trips to HTML so the direction is preserved in the sent mail.
|
||||
*/
|
||||
export const TextDirection = Extension.create({
|
||||
@@ -29,10 +34,10 @@ export const TextDirection = Extension.create({
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
dir: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("dir") || null,
|
||||
default: "auto",
|
||||
parseHTML: (element) => element.getAttribute("dir") || "auto",
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dir ? { dir: attributes.dir } : {},
|
||||
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -424,7 +425,14 @@ function EmailCard({
|
||||
// Plain text fallback
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
||||
return {
|
||||
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
|
||||
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
|
||||
show: t('email_viewer.show_quoted_text'),
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
}),
|
||||
isHtml: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +446,7 @@ function EmailCard({
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
|
||||
|
||||
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
||||
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
||||
@@ -469,6 +477,12 @@ function EmailCard({
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc?.body) return;
|
||||
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
|
||||
// before the first resize so the height reflects the collapsed body.
|
||||
setupQuoteCollapse(doc, {
|
||||
show: t('email_viewer.show_quoted_text'),
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
});
|
||||
const resize = () => {
|
||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||
};
|
||||
@@ -482,7 +496,7 @@ function EmailCard({
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
|
||||
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
const { variant: tagVariant } = useTagDisplay();
|
||||
// A message inside an expanded thread carries its own tags; the collapsed
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview snippet */}
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
@@ -34,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many messages a collapsed thread stands for.
|
||||
*
|
||||
* Built from the tag lozenge so it lines up with the tags it sits next to: the
|
||||
* same shape, and the same group spacing.
|
||||
*/
|
||||
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"gap-0.5",
|
||||
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
}
|
||||
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
rowTint: string | null;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -97,7 +123,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
// Show the originating folder in the aggregate "All …" views.
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
|
||||
const showSourceFolder = isUnifiedView && !!email.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
|
||||
if (colorTag) return colorTag;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
})();
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
@@ -166,21 +186,27 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
ref={ref}
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
data-testid="email-list-item"
|
||||
data-email-id={email.id}
|
||||
data-subject={email.subject || ''}
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
resolvedRowTint ? resolvedRowTint : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
selected && !resolvedRowTint && "shadow-sm",
|
||||
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !resolvedRowTint && "bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
isPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && !resolvedRowTint && "bg-muted"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -254,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
{tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -277,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -318,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
@@ -343,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -374,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -402,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
@@ -436,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
}, ref) {
|
||||
@@ -459,7 +496,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
|
||||
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
|
||||
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
|
||||
@@ -493,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
);
|
||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
// A collapsed row speaks for every message under it, so it carries their tags too.
|
||||
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
|
||||
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
@@ -514,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
rowTint={rowTint}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
/>
|
||||
@@ -593,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{...threadLongPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
colorTag ? colorTag : (
|
||||
rowTint ? rowTint : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isSelected && !rowTint && "shadow-sm",
|
||||
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !rowTint && "bg-accent/40",
|
||||
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||
isThreadPressed && !rowTint && "bg-muted"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -660,7 +700,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onToggle={toggleThreadSelection}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
@@ -702,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
// Matches SingleEmailItem: the sender column sets where
|
||||
// every row's tags and subject begin, so the two have to
|
||||
// agree or thread rows sit 1rem further right.
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -741,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -782,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}>
|
||||
{displayNames.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPinned && (
|
||||
@@ -819,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -850,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -878,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
@@ -892,7 +930,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -147,7 +147,7 @@ export function UnsubscribeBanner({
|
||||
{showConfirm && isDesktop && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
|
||||
className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
|
||||
>
|
||||
<p className="text-sm text-foreground mb-2">
|
||||
{t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
|
||||
|
||||
/**
|
||||
* Badges the browser-tab favicon with the inbox unread count, so new mail is
|
||||
* visible without focusing the tab. See issue #560.
|
||||
*
|
||||
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
|
||||
* default.
|
||||
*
|
||||
* Mounted in the root layout rather than on the mail route: the badge belongs
|
||||
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
|
||||
* cleared the badge, and flickered the icon — on every hop to /settings,
|
||||
* /calendar or /contacts.
|
||||
*
|
||||
* Renders nothing.
|
||||
*/
|
||||
export function FaviconBadge() {
|
||||
// The store's canonical inbox selector. `role === 'inbox'` alone is not
|
||||
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
|
||||
// a delegated setup the first match can be somebody else's inbox.
|
||||
const inboxUnread = useEmailStore(
|
||||
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
|
||||
);
|
||||
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
|
||||
|
||||
useFaviconBadge(inboxUnread, enabled);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -68,10 +68,10 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{message.from && (
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span><bdi>{formatAddress(message.from)}</bdi></div>
|
||||
)}
|
||||
{message.to && message.to.length > 0 && (
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -89,6 +90,7 @@ export function FilterRuleModal({
|
||||
const t = useTranslations("settings.filters");
|
||||
const isEdit = !!rule;
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagName } = useKeywordFormat();
|
||||
|
||||
const [name, setName] = useState(rule?.name || "");
|
||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||
@@ -477,7 +479,7 @@ export function FilterRuleModal({
|
||||
>
|
||||
<option value="">{t("label_placeholder")}</option>
|
||||
{emailKeywords.map((kw) => (
|
||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeSignatureHtml(formData.htmlSignature)
|
||||
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function useSyncIdentities() {
|
||||
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
// Persist to the synced settings (keyed by username, matching how
|
||||
// loadIdentities reads it back) so the choice survives a new browser /
|
||||
// cleared site data and reaches other devices (#507).
|
||||
const username = useAuthStore.getState().username || '';
|
||||
if (username) {
|
||||
// Persist the choice per account in the synced settings store so it
|
||||
// survives clearing site data, follows the user across devices, and shows
|
||||
// up in exported settings (issue #507). JMAP identity ids are account-
|
||||
// scoped, so the default is keyed by the active account.
|
||||
const activeAccountId = useAccountStore.getState().activeAccountId;
|
||||
if (activeAccountId) {
|
||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||
...current,
|
||||
[username]: identity.id,
|
||||
[activeAccountId]: identity.id,
|
||||
});
|
||||
}
|
||||
// Re-sort: move the preferred identity to the front
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SubAddressHelper({
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'absolute top-full right-0 mt-1 z-50',
|
||||
'absolute top-full end-0 mt-1 z-50',
|
||||
'bg-background border border-border rounded-lg shadow-lg',
|
||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { evictAll } from '@/lib/account-state-manager';
|
||||
|
||||
/**
|
||||
* After a master-user impersonation handoff (`GET /api/auth/impersonate`), the
|
||||
* server swaps the slot-0 session cookie but the client's *persisted* account
|
||||
* registry still lists the PREVIOUS account — so the top-left account chip keeps
|
||||
* showing the old mailbox even though the message list is correctly the new one.
|
||||
* Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes
|
||||
* it, because that state lives in localStorage and the impersonation redirect
|
||||
* never reconciles it. (Reported downstream: jabali-panel #646.)
|
||||
*
|
||||
* The impersonate route now redirects to `/?impersonated=1`. Here we drop the
|
||||
* stale persisted account + auth state (and the server-derived caches) and
|
||||
* reload to a clean URL, so the app rehydrates empty and re-derives the single
|
||||
* account from the fresh session cookie — the same result as the manual
|
||||
* sign-out-then-reopen, done automatically. Cookies are untouched, so the
|
||||
* just-granted impersonation session survives the reload.
|
||||
*/
|
||||
const STALE_KEYS = [
|
||||
'account-registry',
|
||||
'auth-storage',
|
||||
'identity-storage',
|
||||
'contact-storage',
|
||||
'calendar-storage',
|
||||
'calendar-notification-storage',
|
||||
];
|
||||
|
||||
export function ImpersonationReconciler() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('impersonated') !== '1') return;
|
||||
|
||||
try {
|
||||
evictAll();
|
||||
} catch {
|
||||
/* in-memory snapshots are best-effort */
|
||||
}
|
||||
for (const key of STALE_KEYS) {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore storage access errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Reload to a clean URL (drop the marker) so the now-empty persisted stores
|
||||
// rehydrate and the app reconnects + re-derives the impersonated account
|
||||
// from the session cookie. The marker is gone on the second load, so this
|
||||
// runs exactly once.
|
||||
params.delete('impersonated');
|
||||
const query = params.toString();
|
||||
window.location.replace(window.location.pathname + (query ? `?${query}` : ''));
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -48,23 +49,41 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const rtl = isDocumentRTL();
|
||||
if (variant === "rail") {
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
});
|
||||
setPopoverStyle(
|
||||
rtl
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
top: rect.bottom + 4,
|
||||
});
|
||||
setPopoverStyle(
|
||||
rtl
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.right,
|
||||
top: rect.bottom + 4,
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
top: rect.bottom + 4,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
@@ -100,6 +119,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
router.push(`/login?mode=add-account` as never);
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
|
||||
e.stopPropagation();
|
||||
const label = account.email || account.username;
|
||||
if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
|
||||
removeAccount(account.id);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
@@ -151,6 +177,8 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
data-testid="account-switcher"
|
||||
data-active-account-id={activeAccountId ?? undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md transition-colors",
|
||||
variant === "rail"
|
||||
@@ -213,10 +241,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSwitch(account.id)}
|
||||
data-testid="account-option"
|
||||
data-account-id={account.id}
|
||||
data-account-email={account.email || account.username}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
isDraggable && "pe-7"
|
||||
(!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
|
||||
)}
|
||||
role="menuitem"
|
||||
disabled={isActive}
|
||||
@@ -259,11 +290,22 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
{isDraggable && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
{!isActive && !account.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemove(e, account)}
|
||||
aria-label={t("remove_account")}
|
||||
title={t("remove_account")}
|
||||
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -274,6 +316,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
data-testid="add-account"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -18,6 +19,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
@@ -70,11 +72,19 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
});
|
||||
setPopoverStyle(
|
||||
isDocumentRTL()
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,6 +191,8 @@ export function NavigationRail({
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
@@ -218,11 +230,19 @@ export function NavigationRail({
|
||||
const updateLogoutPosition = useCallback(() => {
|
||||
if (!logoutBtnRef.current) return;
|
||||
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||
setLogoutPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
});
|
||||
setLogoutPopoverStyle(
|
||||
isDocumentRTL()
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -445,7 +465,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
const logoUrl = withBasePath(resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', appLogoLightUrl, appLogoDarkUrl));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
|
||||
+223
-81
@@ -35,9 +35,20 @@ import {
|
||||
BellOff,
|
||||
Mails,
|
||||
MailOpen,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
hasChildKeywords,
|
||||
type KeywordNode,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
@@ -50,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -79,9 +90,10 @@ interface SidebarProps {
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
|
||||
showAllMailMailbox?: boolean;
|
||||
/** Gated cross-account views in the "All accounts" section. */
|
||||
/** True when the unified view spans multiple login accounts (cross-account).
|
||||
* Drives the section header: "All accounts" when true, else "Unified Mailbox". */
|
||||
crossAccountActive?: boolean;
|
||||
/** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
|
||||
showCrossUnread?: boolean;
|
||||
showCrossStarred?: boolean;
|
||||
showCrossAll?: boolean;
|
||||
@@ -220,8 +232,13 @@ function SidebarRowCounts({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
|
||||
|
||||
<span
|
||||
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
|
||||
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
|
||||
data-testid="folder-counts"
|
||||
data-unread={unreadCount}
|
||||
data-total={totalCount}
|
||||
>
|
||||
{unreadNode}
|
||||
{unreadCount > 0 && totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">/</span>
|
||||
@@ -234,6 +251,9 @@ function SidebarRowCounts({
|
||||
interface SidebarRowProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
/** Progressively shorter renderings of `label`, longest first. The widest one
|
||||
* that fits the row is shown; without this the full label is used. */
|
||||
labelCandidates?: string[];
|
||||
depth?: number;
|
||||
isSelected?: boolean;
|
||||
isVirtual?: boolean;
|
||||
@@ -249,11 +269,17 @@ interface SidebarRowProps {
|
||||
isValidDropTarget?: boolean;
|
||||
isInvalidDropTarget?: boolean;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
/** Stable identifiers for integration tests (not user-visible). */
|
||||
testRole?: string | null;
|
||||
testName?: string;
|
||||
testMailboxId?: string;
|
||||
testShared?: boolean;
|
||||
}
|
||||
|
||||
function SidebarRow({
|
||||
icon,
|
||||
label,
|
||||
labelCandidates,
|
||||
depth = 0,
|
||||
isSelected = false,
|
||||
isVirtual = false,
|
||||
@@ -269,14 +295,24 @@ function SidebarRow({
|
||||
isValidDropTarget,
|
||||
isInvalidDropTarget,
|
||||
onContextMenu,
|
||||
testRole,
|
||||
testName,
|
||||
testMailboxId,
|
||||
testShared,
|
||||
}: SidebarRowProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(dropHandlers || {})}
|
||||
onContextMenu={onContextMenu}
|
||||
data-testid="folder-row"
|
||||
data-folder-role={testRole ?? undefined}
|
||||
data-folder-name={testName ?? undefined}
|
||||
data-mailbox-id={testMailboxId ?? undefined}
|
||||
data-shared={testShared ? 'true' : undefined}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||
@@ -332,7 +368,7 @@ function SidebarRow({
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||
<SidebarRowCounts
|
||||
unread={unread}
|
||||
total={total}
|
||||
@@ -356,6 +392,7 @@ function SidebarSectionHeader({
|
||||
first,
|
||||
icon,
|
||||
sub,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
@@ -366,6 +403,7 @@ function SidebarSectionHeader({
|
||||
first?: boolean;
|
||||
icon?: ReactNode;
|
||||
sub?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
if (isCollapsed) {
|
||||
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
||||
@@ -380,6 +418,9 @@ function SidebarSectionHeader({
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
data-testid={testId}
|
||||
data-section-name={label}
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
|
||||
paddingX,
|
||||
@@ -477,6 +518,10 @@ function MailboxTreeItem({
|
||||
<SidebarRow
|
||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
||||
label={label}
|
||||
testRole={node.role}
|
||||
testName={node.name}
|
||||
testMailboxId={node.id}
|
||||
testShared={node.isShared}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
isVirtual={isVirtualNode}
|
||||
@@ -512,78 +557,120 @@ function MailboxTreeItem({
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_ICON_COLOR: Record<string, string> = {
|
||||
red: "text-red-600/75 dark:text-red-400/75",
|
||||
orange: "text-orange-600/75 dark:text-orange-400/75",
|
||||
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
|
||||
green: "text-green-600/75 dark:text-green-400/75",
|
||||
blue: "text-blue-600/75 dark:text-blue-400/75",
|
||||
purple: "text-purple-600/75 dark:text-purple-400/75",
|
||||
pink: "text-pink-600/75 dark:text-pink-400/75",
|
||||
teal: "text-teal-600/75 dark:text-teal-400/75",
|
||||
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
|
||||
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
|
||||
amber: "text-amber-600/75 dark:text-amber-400/75",
|
||||
lime: "text-lime-600/75 dark:text-lime-400/75",
|
||||
gray: "text-gray-500",
|
||||
};
|
||||
function ShowAllTagsRow({
|
||||
hiddenCount,
|
||||
showAll,
|
||||
onToggle,
|
||||
isCollapsed,
|
||||
}: {
|
||||
hiddenCount: number;
|
||||
showAll: boolean;
|
||||
onToggle: () => void;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
return (
|
||||
<SidebarRow
|
||||
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
|
||||
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
|
||||
depth={0}
|
||||
onClick={onToggle}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TagItem({
|
||||
kw,
|
||||
isSelected,
|
||||
node,
|
||||
selectedKeyword,
|
||||
expandedTags,
|
||||
isCollapsed,
|
||||
onTagSelect,
|
||||
totalCount,
|
||||
unreadCount,
|
||||
onToggleExpand,
|
||||
tagCounts,
|
||||
colorful,
|
||||
}: {
|
||||
kw: KeywordDefinition;
|
||||
isSelected: boolean;
|
||||
node: KeywordNode;
|
||||
selectedKeyword: string | null;
|
||||
expandedTags: Set<string>;
|
||||
isCollapsed: boolean;
|
||||
onTagSelect?: (keywordId: string | null) => void;
|
||||
totalCount: number;
|
||||
unreadCount: number;
|
||||
onToggleExpand: (keywordId: string) => void;
|
||||
tagCounts: Record<string, { total: number; unread: number }>;
|
||||
colorful: boolean;
|
||||
}) {
|
||||
const t = useTranslations('notifications');
|
||||
const palette = KEYWORD_PALETTE[kw.color];
|
||||
const { tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const palette = tagColor(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedTags.has(node.id);
|
||||
const isSelected = selectedKeyword === node.id;
|
||||
// Nested rows are placed by their indentation, so they show their own name.
|
||||
// A root spells out its path, which matters when an intermediate tag is
|
||||
// missing from this client's settings and the row would otherwise read as a
|
||||
// bare leaf name.
|
||||
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
|
||||
const label = labelCandidates[0];
|
||||
// Toasts have the room for the whole thing, and no indentation to lean on,
|
||||
// so they always spell out the full path - otherwise two leaves with the
|
||||
// same name in different branches (e.g. "Personal/Receipts" and
|
||||
// "Work/Receipts") would read as the same tag.
|
||||
const fullLabel = tagNameCandidates(node.id)[0];
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||
tagId: kw.id,
|
||||
onSuccess: (count, _tagLabel) => {
|
||||
tagId: node.id,
|
||||
onSuccess: (count) => {
|
||||
if (count === 1) {
|
||||
toast.success(t('email_tagged'), kw.label);
|
||||
toast.success(t('email_tagged'), fullLabel);
|
||||
} else {
|
||||
toast.success(t('emails_tagged', { count }), kw.label);
|
||||
toast.success(t('emails_tagged', { count }), fullLabel);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('tag_failed'), kw.label);
|
||||
toast.error(t('tag_failed'), fullLabel);
|
||||
},
|
||||
});
|
||||
|
||||
const tagIcon = colorful ? (
|
||||
<Tag
|
||||
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
||||
) : (
|
||||
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
|
||||
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarRow
|
||||
icon={tagIcon}
|
||||
label={kw.label}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unreadCount}
|
||||
total={totalCount}
|
||||
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
/>
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={tagIcon}
|
||||
label={label}
|
||||
labelCandidates={labelCandidates}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
unread={tagCounts[node.id]?.unread ?? 0}
|
||||
total={tagCounts[node.id]?.total ?? 0}
|
||||
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onExpandToggle={() => onToggleExpand(node.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
/>
|
||||
|
||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||
<TagItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
tagCounts={tagCounts}
|
||||
colorful={colorful}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -692,7 +779,7 @@ export function Sidebar({
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
showAllMailMailbox = false,
|
||||
crossAccountActive = false,
|
||||
showCrossUnread = false,
|
||||
showCrossStarred = false,
|
||||
showCrossAll = false,
|
||||
@@ -707,6 +794,8 @@ export function Sidebar({
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||
@@ -749,6 +838,7 @@ export function Sidebar({
|
||||
return new Set();
|
||||
});
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||
// own AccountSwitcher would be a redundant second account UI in the same
|
||||
@@ -812,6 +902,37 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('expandedTags');
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
setExpandedTags(new Set(parsed));
|
||||
} catch (e) {
|
||||
debug.error('Failed to parse expanded tags:', e);
|
||||
}
|
||||
} else {
|
||||
setExpandedTags(
|
||||
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
|
||||
);
|
||||
}
|
||||
}, [emailKeywords]);
|
||||
|
||||
const handleToggleTagExpand = (keywordId: string) => {
|
||||
setExpandedTags((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(keywordId)) {
|
||||
next.delete(keywordId);
|
||||
} else {
|
||||
next.add(keywordId);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
|
||||
} catch { /* storage full or unavailable */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
@@ -822,6 +943,29 @@ export function Sidebar({
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// With nesting off every tag is its own root, so the same rows render through
|
||||
// one path whether or not the ids describe a hierarchy.
|
||||
const tagTree: KeywordNode[] = nestedTags
|
||||
? buildKeywordTree(emailKeywords)
|
||||
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
|
||||
|
||||
// Counts arrive from a separate JMAP round trip, one batch per group of tags;
|
||||
// a tag with no count yet is treated as visible rather than blanking it and
|
||||
// filling it back in. A tag the server answered for with zero unread hides,
|
||||
// which is the point of the setting.
|
||||
const isTagVisible = (node: KeywordNode) => {
|
||||
if (showAllTags || node.id === selectedKeyword) return true;
|
||||
const visibility = getKeywordVisibility(node);
|
||||
if (visibility === 'hide') return false;
|
||||
if (visibility === 'unread') {
|
||||
const count = tagCounts[node.id];
|
||||
return !count || count.unread > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
|
||||
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
// own collapsible group. The active account's tree comes from the
|
||||
// `mailboxes` prop (which is the live email-store value); other accounts
|
||||
@@ -858,16 +1002,8 @@ export function Sidebar({
|
||||
// window listener, so without this guard typing in a new email (the
|
||||
// contentEditable composer, the subject field, search, etc.) toggled the
|
||||
// selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// composedPath-based so it also sees the QuotedHtml shadow island (#654).
|
||||
if (isEditableEventTarget(e)) return;
|
||||
if (!selectedMailbox || isCollapsed) return;
|
||||
|
||||
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
|
||||
@@ -1017,20 +1153,10 @@ export function Sidebar({
|
||||
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||
{showAllMailMailbox && (
|
||||
<SidebarRow
|
||||
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('mailboxes.all_mail')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
|
||||
onClick={() => onMailboxSelect?.('__all_mail__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("all_accounts")}
|
||||
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
|
||||
expanded={unifiedExpanded}
|
||||
onToggle={toggleUnified}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -1047,6 +1173,9 @@ export function Sidebar({
|
||||
key={unifiedId}
|
||||
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
|
||||
label={t(`unified_${count.role}`)}
|
||||
testRole={count.role}
|
||||
testName={`unified-${count.role}`}
|
||||
testMailboxId={unifiedId}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={count.unreadEmails}
|
||||
@@ -1068,6 +1197,8 @@ export function Sidebar({
|
||||
key={id}
|
||||
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
|
||||
label={label}
|
||||
testName={id}
|
||||
testMailboxId={id}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unread}
|
||||
@@ -1197,6 +1328,7 @@ export function Sidebar({
|
||||
expanded={sharedExpanded}
|
||||
onToggle={toggleShared}
|
||||
isCollapsed={isCollapsed}
|
||||
testId="section-shared"
|
||||
/>
|
||||
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
@@ -1211,6 +1343,7 @@ export function Sidebar({
|
||||
isCollapsed={isCollapsed}
|
||||
sub
|
||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
testId="section-shared-account"
|
||||
/>
|
||||
{accountExpanded && !isCollapsed && account.children.map((child) => (
|
||||
<MailboxTreeItem
|
||||
@@ -1246,18 +1379,27 @@ export function Sidebar({
|
||||
/>
|
||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{emailKeywords.map((kw) => (
|
||||
{visibleTagTree.map((node) => (
|
||||
<TagItem
|
||||
key={kw.id}
|
||||
kw={kw}
|
||||
isSelected={selectedKeyword === kw.id}
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
totalCount={tagCounts[kw.id]?.total ?? 0}
|
||||
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
tagCounts={tagCounts}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
))}
|
||||
{(hiddenTagCount > 0 || showAllTags) && (
|
||||
<ShowAllTagsRow
|
||||
hiddenCount={hiddenTagCount}
|
||||
showAll={showAllTags}
|
||||
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,16 @@ import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { findDraftIdentityId } from "@/lib/reply-identity";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -19,8 +24,6 @@ interface ProEmailTabBodyProps {
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email): ProReplyContext {
|
||||
const textPartId = email.textBody?.[0]?.partId ?? '';
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
|
||||
return {
|
||||
from: email.from,
|
||||
replyToAddresses: email.replyTo,
|
||||
@@ -28,8 +31,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
|
||||
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
|
||||
...getQuoteBodies(email),
|
||||
receivedAt: email.receivedAt,
|
||||
accountId: email.accountId,
|
||||
attachments: email.attachments,
|
||||
@@ -56,8 +58,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
@@ -135,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
// Mirrors handleForward, but attaches the original as a message/rfc822
|
||||
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
|
||||
// This is a separate, self-contained render path from the main Mail
|
||||
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
|
||||
// open compose tabs directly via useProTabStore, not through
|
||||
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
|
||||
// wiring rather than falling out of the page.tsx fix automatically.
|
||||
const handleForwardAsAttachment = useCallback(() => {
|
||||
if (!email) return;
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
} = useSettingsStore.getState();
|
||||
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
});
|
||||
if (!payload) return;
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: {
|
||||
subject: email.subject,
|
||||
attachments: [payload.attachment],
|
||||
},
|
||||
sourceEmailId: email.id,
|
||||
// payload.subject is intentionally blank for a subject-less email (to
|
||||
// match normal Forward's *composer* subject behavior - see
|
||||
// buildForwardAsAttachmentPayload). The Pro tab *title* is a separate
|
||||
// UI label that still needs a sensible fallback, same as handleForward
|
||||
// above uses - reusing payload.subject here would give the tab an
|
||||
// empty title instead of e.g. "Fwd: New message".
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
@@ -190,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
}
|
||||
}, [client, markAsRead]);
|
||||
|
||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
||||
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
|
||||
if (!email || email.id !== emailId) return;
|
||||
// Drop existing color keywords, optionally add the new one. Matches the
|
||||
// mail page's local optimistic update.
|
||||
// Toggle one tag, or clear them all. Matches the mail page's local
|
||||
// optimistic update, down to reaching tags this client cannot name.
|
||||
const keywords = { ...(email.keywords ?? {}) };
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (color) {
|
||||
const def = settingsKeywords.find((k) => k.color === color);
|
||||
if (def) keywords[`$label:${def.id}`] = true;
|
||||
if (tagId === null) {
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||
.filter(key => keywords[key]);
|
||||
if (activeKeys.length > 0) {
|
||||
for (const key of activeKeys) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
} else {
|
||||
keywords[KEYWORD_PREFIX + tagId] = true;
|
||||
}
|
||||
}
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
||||
}, [email, setEmailKeywordsLocal]);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
@@ -235,15 +291,21 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const bodyText = email.bodyValues
|
||||
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
||||
: '';
|
||||
const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]
|
||||
? email.bodyValues[email.htmlBody[0].partId].value
|
||||
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||
const draftHtmlPart = email.htmlBody?.[0];
|
||||
const htmlBody = draftHtmlPart?.partId
|
||||
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
|
||||
&& email.bodyValues?.[draftHtmlPart.partId]
|
||||
? email.bodyValues[draftHtmlPart.partId].value
|
||||
: undefined;
|
||||
|
||||
// Preserve the identity that matches the draft's From address.
|
||||
const draftFromEmail = email.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find((id) => id.email === draftFromEmail)
|
||||
: null;
|
||||
// Preserve the identity the draft was composed with — match name + address
|
||||
// against the same list the composer renders (see findDraftIdentityId).
|
||||
const composerIdentities = multiAccountIdentities.enabled
|
||||
? multiAccountIdentities.allIdentities
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityId(composerIdentities, email.from?.[0]);
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
@@ -258,14 +320,14 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (email.cc?.length || 0) > 0,
|
||||
showBcc: (email.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
selectedIdentityId: matchedIdentityId,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: email.id,
|
||||
},
|
||||
});
|
||||
closeTab(tabId);
|
||||
}, [email, identities, openComposeTab, closeTab, tabId, t]);
|
||||
}, [email, identities, multiAccountIdentities, openComposeTab, closeTab, tabId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
@@ -276,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onForwardAsAttachment={handleForwardAsAttachment}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onSetTag={handleSetTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onEditDraft={handleEditDraft}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import arMessages from '@/locales/ar/common.json';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import daMessages from '@/locales/da/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
@@ -31,6 +32,7 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
ar: arMessages,
|
||||
cs: csMessages,
|
||||
da: daMessages,
|
||||
de: deMessages,
|
||||
|
||||
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { KeywordSettings } from '../keyword-settings';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
|
||||
// Mock SettingsSection to just render children
|
||||
vi.mock('../settings-section', () => ({
|
||||
// Mock SettingsSection to just render children, keeping the real controls
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('KeywordSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
|
||||
});
|
||||
|
||||
it('renders all default keywords', () => {
|
||||
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
|
||||
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reset defaults button', () => {
|
||||
render(<KeywordSettings />);
|
||||
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows add form when add button clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
|
||||
expect(kw?.label).toBe('Crimson');
|
||||
});
|
||||
|
||||
it('resets to defaults when reset button clicked', () => {
|
||||
// Modify keywords first
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
useSettingsStore.getState().removeKeyword('blue');
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
|
||||
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('reset_defaults'));
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
|
||||
});
|
||||
|
||||
it('normalizes label to id correctly', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
|
||||
expect(added.id).toBe('my-custom-tag');
|
||||
expect(added.label).toBe('My Custom Tag!');
|
||||
});
|
||||
|
||||
it('offers no parent picker while nesting is off', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nests a new tag under the selected parent', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
|
||||
});
|
||||
|
||||
it('shows nested tags by their full path', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a path that would exceed the keyword length limit', () => {
|
||||
const deepId = 'a'.repeat(240);
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
|
||||
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
|
||||
|
||||
expect(screen.getByText('too_long')).toBeInTheDocument();
|
||||
expect(screen.getByText('add').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('locks the name and the delete action of a tag that has nested tags', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
|
||||
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('edit')[0]);
|
||||
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
||||
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults every tag to always visible in the sidebar', () => {
|
||||
render(<KeywordSettings />);
|
||||
|
||||
const pickers = screen.getAllByLabelText('visibility_field');
|
||||
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
|
||||
});
|
||||
|
||||
it('stores the visibility chosen for a tag', () => {
|
||||
render(<KeywordSettings />);
|
||||
|
||||
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { clearCachedData } from '@/lib/clear-cached-data';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
@@ -54,6 +55,7 @@ export function AboutDataSettings() {
|
||||
useSettingsStore();
|
||||
const { settingsSyncEnabled } = useConfig();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
@@ -105,6 +107,15 @@ export function AboutDataSettings() {
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleRefreshCache = () => {
|
||||
if (showRefreshConfirm) {
|
||||
clearCachedData(); // reloads the page
|
||||
} else {
|
||||
setShowRefreshConfirm(true);
|
||||
setTimeout(() => setShowRefreshConfirm(false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (showResetConfirm) {
|
||||
resetToDefaults();
|
||||
@@ -187,6 +198,16 @@ export function AboutDataSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')}>
|
||||
<Button
|
||||
variant={showRefreshConfirm ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleRefreshCache}
|
||||
>
|
||||
{showRefreshConfirm ? tCommon('yes') : t('refresh_cache.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
|
||||
<Button
|
||||
variant={showResetConfirm ? 'destructive' : 'outline'}
|
||||
|
||||
@@ -475,7 +475,7 @@ export function CalendarManagementSettings() {
|
||||
{colorPickerId === cal.id && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
className="absolute start-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
|
||||
@@ -17,7 +17,16 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, KeyboardSensor,
|
||||
useSensor, useSensors, type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext, verticalListSortingStrategy, useSortable,
|
||||
arrayMove, sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
|
||||
|
||||
@@ -81,7 +90,7 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute left-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
|
||||
className="absolute start-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
|
||||
>
|
||||
{ICON_CHOICES.map(({ name, icon: Icon }) => (
|
||||
<button
|
||||
@@ -102,10 +111,47 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a folder row with a drag handle so it can be reordered within its
|
||||
* sibling group. The handle carries the dnd-kit listeners; the rest of the row
|
||||
* (buttons, inline editors) stays fully interactive.
|
||||
*/
|
||||
function SortableFolderRow({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex items-center px-1 text-muted-foreground/40 hover:text-foreground cursor-grab active:cursor-grabbing touch-none rounded-md focus:outline-none focus:ring-2 focus:ring-ring flex-shrink-0"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderSettings() {
|
||||
const t = useTranslations('settings.folders');
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes } = useEmailStore();
|
||||
|
||||
const sensors = useSensors(
|
||||
// Small activation distance so clicking the row's buttons still works.
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const { folderIcons, setFolderIcon } = useSettingsStore();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
|
||||
@@ -129,6 +175,31 @@ export function FolderSettings() {
|
||||
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
|
||||
const folderTree = buildMailboxTree(ownMailboxes);
|
||||
|
||||
// Reorder folders within a sibling group (same parent). Drops onto a folder
|
||||
// in a different group are ignored — this reorders, it doesn't reparent.
|
||||
const handleFolderDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !client) return;
|
||||
|
||||
const groups: MailboxNode[][] = [];
|
||||
const collectGroups = (nodes: MailboxNode[]) => {
|
||||
groups.push(nodes);
|
||||
nodes.forEach(n => { if (n.children.length > 0) collectGroups(n.children); });
|
||||
};
|
||||
collectGroups(folderTree);
|
||||
|
||||
const group = groups.find(g => g.some(n => n.id === active.id));
|
||||
if (!group) return;
|
||||
const oldIndex = group.findIndex(n => n.id === active.id);
|
||||
const newIndex = group.findIndex(n => n.id === over.id);
|
||||
if (newIndex < 0) return; // dropped outside the active folder's sibling group
|
||||
|
||||
const orderedIds = arrayMove(group, oldIndex, newIndex).map(n => n.id);
|
||||
reorderMailboxes(client, orderedIds).catch(() => {
|
||||
toast.error(t('reorder_error'));
|
||||
});
|
||||
};
|
||||
|
||||
const getRoleMailboxId = (role: string): string => {
|
||||
const mb = ownMailboxes.find(m => m.role === role);
|
||||
return mb?.id ?? '';
|
||||
@@ -385,6 +456,7 @@ export function FolderSettings() {
|
||||
|
||||
return (
|
||||
<div key={mb.id}>
|
||||
<SortableFolderRow id={mb.id} title={t('reorder')}>
|
||||
<div
|
||||
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
@@ -482,12 +554,15 @@ export function FolderSettings() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableFolderRow>
|
||||
{/* Inline subfolder creation */}
|
||||
{renderCreateInline(mb.id, depth + 1)}
|
||||
{/* Render children if expanded */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map(child => renderFolderNode(child))}
|
||||
<SortableContext items={node.children.map(c => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{node.children.map(child => renderFolderNode(child))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -505,7 +580,11 @@ export function FolderSettings() {
|
||||
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
folderTree.map(node => renderFolderNode(node))
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleFolderDragEnd}>
|
||||
<SortableContext items={folderTree.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{folderTree.map(node => renderFolderNode(node))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,15 +2,35 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import {
|
||||
useSettingsStore,
|
||||
KEYWORD_PALETTE,
|
||||
KEYWORD_PALETTE_ROWS,
|
||||
getKeywordVisibility,
|
||||
type KeywordDefinition,
|
||||
type KeywordVisibility,
|
||||
} from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
keywordLevels,
|
||||
type KeywordNode,
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { formatKeyword, keywordRenderings } from "@/lib/keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { TagBadge } from "@/components/email/tag-badge";
|
||||
|
||||
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||
|
||||
/** Lighter, base and darker shade of each hue, one row per shade. */
|
||||
function KeywordColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -19,19 +39,23 @@ function KeywordColorPicker({
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PALETTE_KEYS.map((colorKey) => (
|
||||
<button
|
||||
key={colorKey}
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
aria-label={colorKey}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
{KEYWORD_PALETTE_ROWS.map((row, index) => (
|
||||
<div key={index} className="flex flex-wrap gap-1.5">
|
||||
{row.map((colorKey) => (
|
||||
<button
|
||||
key={colorKey}
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
aria-label={colorKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -39,8 +63,11 @@ function KeywordColorPicker({
|
||||
|
||||
function KeywordRow({
|
||||
keyword,
|
||||
keywords,
|
||||
nestedTags,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onVisibilityChange,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
@@ -49,8 +76,11 @@ function KeywordRow({
|
||||
isDragging,
|
||||
}: {
|
||||
keyword: KeywordDefinition;
|
||||
keywords: KeywordDefinition[];
|
||||
nestedTags: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onVisibilityChange: (visibility: KeywordVisibility) => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: () => void;
|
||||
@@ -59,7 +89,16 @@ function KeywordRow({
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const palette = KEYWORD_PALETTE[keyword.color];
|
||||
const hasChildren = hasChildKeywords(keyword.id, keywords);
|
||||
// Measured with the prefix attached, since that is what occupies the column.
|
||||
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
|
||||
.map((rendering) => KEYWORD_PREFIX + rendering);
|
||||
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
|
||||
const visibilityOptions = [
|
||||
{ value: "show", label: t("visibility.show") },
|
||||
{ value: "unread", label: t("visibility.unread") },
|
||||
{ value: "hide", label: t("visibility.hide") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,9 +114,23 @@ function KeywordRow({
|
||||
)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
|
||||
</div>
|
||||
<span
|
||||
ref={keywordRef}
|
||||
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
|
||||
title={KEYWORD_PREFIX + keyword.id}
|
||||
>
|
||||
{shortenedKeyword}
|
||||
</span>
|
||||
<Select
|
||||
value={getKeywordVisibility(keyword)}
|
||||
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
|
||||
options={visibilityOptions}
|
||||
ariaLabel={t("visibility_field")}
|
||||
className="text-xs py-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,8 +143,9 @@ function KeywordRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={t("delete")}
|
||||
disabled={hasChildren}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||
title={hasChildren ? t("has_children_delete") : t("delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -102,37 +156,74 @@ function KeywordRow({
|
||||
|
||||
function KeywordEditForm({
|
||||
initial,
|
||||
keywords,
|
||||
existingIds,
|
||||
nestedTags,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: KeywordDefinition;
|
||||
keywords: KeywordDefinition[];
|
||||
existingIds: string[];
|
||||
nestedTags: boolean;
|
||||
onSave: (keyword: KeywordDefinition) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const [label, setLabel] = useState(initial?.label || "");
|
||||
const [color, setColor] = useState(initial?.color || "blue");
|
||||
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
|
||||
const isEditing = !!initial;
|
||||
|
||||
const normalizedId = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
// Renaming or re-parenting a tag rewrites the keyword on every message below
|
||||
// it, and this client only knows about the tags in its own settings - the
|
||||
// server may hold nested keywords created elsewhere. Freeze the identity of a
|
||||
// tag that has children and allow the color to change.
|
||||
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
|
||||
|
||||
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
||||
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
|
||||
|
||||
// Every tag is a candidate parent except the one being edited and anything
|
||||
// already below it, which would detach the branch from its own root.
|
||||
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
|
||||
const collectParentOptions = (nodes: KeywordNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
|
||||
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
|
||||
collectParentOptions(node.children);
|
||||
}
|
||||
};
|
||||
collectParentOptions(buildKeywordTree(keywords));
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isValid) return;
|
||||
if (isLocked && initial) {
|
||||
onSave({ ...initial, color });
|
||||
return;
|
||||
}
|
||||
onSave({ id: normalizedId, label: label.trim(), color });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
{nestedTags && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("parent_field")}
|
||||
</label>
|
||||
<Select
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={parentOptions}
|
||||
disabled={isLocked}
|
||||
ariaLabel={t("parent_field")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("label_field")}
|
||||
@@ -141,15 +232,29 @@ function KeywordEditForm({
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={isLocked}
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
||||
placeholder={t("label_placeholder")}
|
||||
autoFocus
|
||||
maxLength={30}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
/>
|
||||
{nestedTags && normalizedId.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
|
||||
{KEYWORD_PREFIX + normalizedId}
|
||||
</p>
|
||||
)}
|
||||
{isLocked && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
|
||||
)}
|
||||
{isDuplicate && (
|
||||
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
||||
)}
|
||||
{isTooLong && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||
@@ -182,7 +287,7 @@ function KeywordEditForm({
|
||||
|
||||
export function KeywordSettings() {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
||||
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
|
||||
useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const { fetchTagCounts } = useEmailStore();
|
||||
@@ -260,12 +365,19 @@ export function KeywordSettings() {
|
||||
removeKeyword(id);
|
||||
};
|
||||
|
||||
const handleResetDefaults = () => {
|
||||
reorderKeywords(DEFAULT_KEYWORDS);
|
||||
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
||||
updateKeyword(id, { visibility });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
|
||||
<ToggleSwitch
|
||||
checked={nestedTags}
|
||||
onChange={(checked) => updateSetting("nestedTags", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<div className="space-y-2">
|
||||
{isMigrating && (
|
||||
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
||||
@@ -278,7 +390,9 @@ export function KeywordSettings() {
|
||||
<KeywordEditForm
|
||||
key={keyword.id}
|
||||
initial={keyword}
|
||||
keywords={emailKeywords}
|
||||
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
||||
nestedTags={nestedTags}
|
||||
onSave={handleEdit}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
@@ -286,11 +400,14 @@ export function KeywordSettings() {
|
||||
<KeywordRow
|
||||
key={keyword.id}
|
||||
keyword={keyword}
|
||||
keywords={emailKeywords}
|
||||
nestedTags={nestedTags}
|
||||
onEdit={() => {
|
||||
setEditingId(keyword.id);
|
||||
setIsAdding(false);
|
||||
}}
|
||||
onDelete={() => handleDelete(keyword.id)}
|
||||
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
@@ -303,7 +420,9 @@ export function KeywordSettings() {
|
||||
|
||||
{isAdding ? (
|
||||
<KeywordEditForm
|
||||
keywords={emailKeywords}
|
||||
existingIds={existingIds}
|
||||
nestedTags={nestedTags}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
/>
|
||||
@@ -320,14 +439,6 @@ export function KeywordSettings() {
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("add_keyword")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetDefaults}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("reset_defaults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -118,19 +118,26 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
|
||||
// Cross-account "All accounts" views, each gated independently by the admin.
|
||||
const connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]);
|
||||
const unifiedCrossAccountAllowed = isFeatureEnabled('unifiedCrossAccountEnabled');
|
||||
// Unified Mailbox entries (All mail / Unread / Starred), each gated independently
|
||||
// by the admin. Scope (single account vs. cross-account) is governed by
|
||||
// `unifiedCrossAccount`; the folder picker below narrows which own folders feed them.
|
||||
const crossViews = [
|
||||
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
|
||||
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
|
||||
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
|
||||
] as const;
|
||||
// The folder picker narrows the own folders included in the entries above; show
|
||||
// it once the user has enabled at least one of them.
|
||||
const anyCrossEnabled = enableCrossUnreadView || enableCrossStarredView || enableCrossAllView;
|
||||
const anyCrossAllowed = crossViews.some(c => c.allowed);
|
||||
|
||||
// Own (non-shared) folders and the active account's All Mail selection. The
|
||||
// selection is per account: a missing entry = never configured, which
|
||||
@@ -231,7 +238,14 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
|
||||
<ToggleSwitch
|
||||
checked={faviconUnreadBadge}
|
||||
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{!isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
@@ -244,6 +258,21 @@ export function LayoutSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.cross_account.label')}
|
||||
description={t('unified_mailbox.cross_account.description')}
|
||||
locked={isSettingLocked('unifiedCrossAccount')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={unifiedCrossAccount}
|
||||
onChange={(v) => updateSetting('unifiedCrossAccount', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
|
||||
<SettingItem
|
||||
@@ -259,8 +288,9 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
|
||||
{enableUnifiedMailbox && anyCrossAllowed && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||
|
||||
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
|
||||
allowed && !isSettingHidden(setting) && (
|
||||
<SettingItem
|
||||
@@ -279,21 +309,9 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
|
||||
<SettingItem
|
||||
label={t('all_mail.label')}
|
||||
description={t('all_mail.description')}
|
||||
locked={isSettingLocked('enableAllMailView')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableAllMailView}
|
||||
onChange={(v) => updateSetting('enableAllMailView', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && enableAllMailView && (
|
||||
{enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ReadingSettings() {
|
||||
hoverActionsCorner,
|
||||
hideInlineImageAttachments,
|
||||
attachmentImagePreviewsEnabled,
|
||||
messageSpacing,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
@@ -115,6 +116,20 @@ export function ReadingSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{!isSettingHidden('messageSpacing') && (
|
||||
<SettingItem label={t('message_spacing.label')} description={t('message_spacing.description')} locked={isSettingLocked('messageSpacing')}>
|
||||
<Select
|
||||
value={messageSpacing}
|
||||
onChange={(value) => updateSetting('messageSpacing', value as typeof messageSpacing)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('message_spacing.auto') },
|
||||
{ value: 'always', label: t('message_spacing.always') },
|
||||
{ value: 'edge', label: t('message_spacing.edge') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{!isSettingHidden('deleteAction') && (
|
||||
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -111,15 +111,24 @@ interface SelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function Select({ value, onChange, options }: SelectProps) {
|
||||
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
dir="auto"
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
|
||||
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
|
||||
@@ -4,9 +4,12 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RichTextEditor } from '@/components/email/rich-text-editor';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
||||
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
@@ -27,6 +30,7 @@ export function VacationSettings() {
|
||||
toDate,
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
@@ -40,6 +44,8 @@ export function VacationSettings() {
|
||||
const [localToDate, setLocalToDate] = useState(toDate || '');
|
||||
const [localSubject, setLocalSubject] = useState(subject);
|
||||
const [localTextBody, setLocalTextBody] = useState(textBody);
|
||||
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
|
||||
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||
|
||||
@@ -55,7 +61,9 @@ export function VacationSettings() {
|
||||
setLocalToDate(toDate || '');
|
||||
setLocalSubject(subject);
|
||||
setLocalTextBody(textBody);
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody]);
|
||||
setHtmlEnabled(!!htmlBody);
|
||||
setLocalHtmlBody(htmlBody || '');
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const warnings: string[] = [];
|
||||
@@ -70,13 +78,14 @@ export function VacationSettings() {
|
||||
warnings.push(t('warnings.start_in_past'));
|
||||
}
|
||||
|
||||
if (localEnabled && !localTextBody.trim()) {
|
||||
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
||||
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
|
||||
warnings.push(t('warnings.empty_body'));
|
||||
}
|
||||
|
||||
setValidationWarnings(warnings);
|
||||
return warnings;
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, t]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
@@ -87,7 +96,8 @@ export function VacationSettings() {
|
||||
(localFromDate || null) !== (fromDate || null) ||
|
||||
(localToDate || null) !== (toDate || null) ||
|
||||
localSubject !== subject ||
|
||||
localTextBody !== textBody;
|
||||
localTextBody !== textBody ||
|
||||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '');
|
||||
|
||||
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
|
||||
|
||||
@@ -96,13 +106,25 @@ export function VacationSettings() {
|
||||
validate();
|
||||
if (hasBlockingError) return;
|
||||
|
||||
const sanitizedHtml =
|
||||
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
|
||||
? sanitizeEmailHtml(localHtmlBody)
|
||||
: null;
|
||||
// Keep a plain-text part as the fallback for clients that don't render
|
||||
// HTML. If the user left it blank, derive it from the HTML body.
|
||||
const textBody =
|
||||
localTextBody.trim() || !sanitizedHtml
|
||||
? localTextBody
|
||||
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
|
||||
|
||||
try {
|
||||
await updateVacationResponse(client, {
|
||||
isEnabled: localEnabled,
|
||||
fromDate: localFromDate || null,
|
||||
toDate: localToDate || null,
|
||||
subject: localSubject,
|
||||
textBody: localTextBody,
|
||||
textBody,
|
||||
htmlBody: sanitizedHtml,
|
||||
}, managedAccountId ?? undefined);
|
||||
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
@@ -217,28 +239,66 @@ export function VacationSettings() {
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
|
||||
/>
|
||||
</div>
|
||||
<SettingItem
|
||||
label={t('message.html_label')}
|
||||
description={t('message.html_description')}
|
||||
>
|
||||
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
|
||||
</SettingItem>
|
||||
{htmlEnabled && (
|
||||
<div className="pb-3">
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<RichTextEditor
|
||||
content={localHtmlBody}
|
||||
onChange={setLocalHtmlBody}
|
||||
placeholder={t('message.html_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{localTextBody.trim() && (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{(() => {
|
||||
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
||||
if (!localTextBody.trim() && !showHtmlPreview) return null;
|
||||
return (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
{showHtmlPreview ? (
|
||||
<div
|
||||
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline"
|
||||
// Preview renders into the app's own DOM. Intercept anchor
|
||||
// clicks so following a link doesn't navigate the whole app
|
||||
// away (and lose the unsaved responder), opening a new tab.
|
||||
onClick={(e) => {
|
||||
const anchor = (e.target as HTMLElement).closest('a');
|
||||
if (anchor?.href) {
|
||||
e.preventDefault();
|
||||
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
})()}
|
||||
|
||||
{validationWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function PlaceholderFillModal({
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t('preview')}</p>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap p-3 rounded-md bg-muted/50 border border-border max-h-32 overflow-y-auto">
|
||||
{preview}
|
||||
{template.isHTML ? <div dangerouslySetInnerHTML={{ __html: preview }}></div> : preview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Star, Plus } from 'lucide-react';
|
||||
import { Star, Plus, Square, CheckSquare } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { validateTemplateName } from '@/lib/template-utils';
|
||||
import { BUILT_IN_PLACEHOLDERS } from '@/lib/template-types';
|
||||
@@ -38,6 +38,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
const [category, setCategory] = useState(template?.category || '');
|
||||
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
|
||||
const [body, setBody] = useState(template?.body || initialData?.body || '');
|
||||
const [isHTML, setIsHTML] = useState(template?.isHTML || false);
|
||||
const [toRecipients, setToRecipients] = useState(
|
||||
template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || ''
|
||||
);
|
||||
@@ -76,6 +77,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
name: name.trim(),
|
||||
subject,
|
||||
body,
|
||||
isHTML,
|
||||
category: category.trim(),
|
||||
defaultRecipients: to.length || cc.length || bcc.length
|
||||
? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined }
|
||||
@@ -190,6 +192,14 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHTML(!isHTML)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isHTML ? <CheckSquare className={cn('w-4 h-4')}></CheckSquare> : <Square className={cn('w-4 h-4')}></Square>}
|
||||
HTML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
@@ -232,7 +242,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
>
|
||||
<option value="">{tSettings('default_identity')}</option>
|
||||
{identities.map((id) => (
|
||||
<option key={id.id} value={id.id}>
|
||||
<option key={id.id} value={id.id} dir="ltr">
|
||||
{id.name ? `${id.name} <${id.email}>` : id.email}
|
||||
</option>
|
||||
))}
|
||||
@@ -275,7 +285,7 @@ function PlaceholderDropdown({
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
|
||||
<div className="absolute end-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
|
||||
<div className="p-1">
|
||||
{BUILT_IN_PLACEHOLDERS.map((p) => (
|
||||
<button
|
||||
|
||||
@@ -108,6 +108,8 @@ interface ContextMenuItemProps {
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
shortcut?: string;
|
||||
/** Stable hook for integration tests (not user-visible). */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
@@ -117,10 +119,12 @@ export function ContextMenuItem({
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
shortcut,
|
||||
testId,
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
|
||||
@@ -153,12 +157,15 @@ interface ContextMenuSubMenuProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** Stable hook for integration tests (not user-visible). */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuSubMenu({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
testId,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
||||
@@ -232,6 +239,7 @@ export function ContextMenuSubMenu({
|
||||
role="menuitem"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
data-testid={testId}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
|
||||
@@ -76,6 +76,19 @@ export function FlagES(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Catalonia – Senyera: four red horizontal bars on a yellow field */
|
||||
export function FlagCAT(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 18" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="27" height="18" fill="#FCDD09" />
|
||||
<rect y="2" width="27" height="2" fill="#DA121A" />
|
||||
<rect y="6" width="27" height="2" fill="#DA121A" />
|
||||
<rect y="10" width="27" height="2" fill="#DA121A" />
|
||||
<rect y="14" width="27" height="2" fill="#DA121A" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Italy – Green, White, Red vertical */
|
||||
export function FlagIT(props: FlagProps) {
|
||||
return (
|
||||
@@ -274,6 +287,18 @@ const skCross =
|
||||
" 7.22 49.54 7.11-.62-20.5-6.6-46.33-6.6-46.33s12.3.96 17.22.96c4.92 0" +
|
||||
" 17.21-.96 17.21-.96s-5.97 25.83-6.6 46.33c12.18.1 29.72-.49 49.55-7.11" +
|
||||
" 0 0-.62 8.3-.62 17.98 0 9.67.62 17.98.62 17.98-19.86-6.64-37.42-7.22-49.6-7.12v32.37";
|
||||
/** United Arab Emirates – Red hoist stripe, green/white/black horizontal bands */
|
||||
export function FlagAE(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="4" height="3" fill="#fff" />
|
||||
<rect x="1" width="3" height="1" fill="#00732F" />
|
||||
<rect x="1" y="2" width="3" height="1" fill="#000" />
|
||||
<rect width="1" height="3" fill="#FF0000" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const skHills =
|
||||
"M270 329.1c-24.87 0-38.19 34.46-38.19 34.46s-7.4-16.34-27.68-16.34" +
|
||||
"c-13.73 0-23.82 12.2-30.25 23.5 24.97 39.7 64.8 64.2 96.11 79.28" +
|
||||
@@ -297,6 +322,7 @@ export function FlagSK(props: FlagProps) {
|
||||
|
||||
/** Map locale codes to flag components */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||
ca: FlagCAT,
|
||||
cs: FlagCS,
|
||||
sk: FlagSK,
|
||||
da: FlagDK,
|
||||
@@ -319,4 +345,5 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
zh: FlagCN,
|
||||
fa: FlagIR,
|
||||
he: FlagIL,
|
||||
ar: FlagAE,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'ca', label: 'Català' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'sk', label: 'Slovenčina' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
|
||||
+42
-18
@@ -21,6 +21,7 @@ export interface Toast {
|
||||
onClick?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
action?: ToastAction;
|
||||
secondaryAction?: ToastAction;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
@@ -117,25 +118,48 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
{toast.message && (
|
||||
<p className="text-[12px] mt-1 text-muted-foreground leading-snug">{toast.message}</p>
|
||||
)}
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.action!.onClick();
|
||||
dismiss();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"mt-2 text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
|
||||
"text-foreground"
|
||||
{(toast.action || toast.secondaryAction) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{toast.secondaryAction && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.secondaryAction!.onClick();
|
||||
dismiss();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
>
|
||||
{toast.secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.action!.onClick();
|
||||
dismiss();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
|
||||
"text-foreground"
|
||||
)}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# VNCmail+ — Admin Deployment Guide (microk8s)
|
||||
|
||||
Deploy VNCmail+ (VNC's Bulwark fork) as a container at **`vncmail.sandbox.vnc.de`**,
|
||||
**alongside** the existing `bulwark.sandbox.vnc.de`. Plain `kubectl apply` — no
|
||||
GitOps needed.
|
||||
|
||||
> Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/
|
||||
> telemetry to `/app/data`, which needs persistent volumes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What you are deploying
|
||||
|
||||
| # | Object | File | Purpose |
|
||||
|---|--------|------|---------|
|
||||
| 1 | Namespace `vncmail` | `namespace.yaml` | Isolates the app |
|
||||
| 2 | 4× PersistentVolumeClaim | `pvc.yaml` | `/app/data/{settings,admin,admin-state,telemetry}` |
|
||||
| 3 | Secret `vncmail-env` | `secret.yaml` *(you create it)* | App config (JMAP URL, session secret, branding) |
|
||||
| 4 | Secret `ghcr-pull` | *(you create it — command below)* | Pull the private image from GHCR |
|
||||
| 5 | Deployment `vncmail-plus` | `deployment.yaml` | The app pod |
|
||||
| 6 | Service `vncmail-plus` | `service.yaml` | ClusterIP :80 → pod :3000 |
|
||||
| 7 | Ingress `vncmail-plus` | `ingress.yaml` | TLS host `vncmail.sandbox.vnc.de` |
|
||||
|
||||
**Image:** `ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest`
|
||||
(built automatically by CI from the `dev` branch). For anything beyond the
|
||||
sandbox, pin a digest — see §5.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pre-flight — confirm 3 cluster values (2 min)
|
||||
|
||||
The manifests use microk8s defaults. **Copy the exact values the existing
|
||||
Bulwark uses** so VNCmail+ matches your cluster:
|
||||
|
||||
```bash
|
||||
# Find bulwark's ingress and read off its class + cert-manager annotations:
|
||||
kubectl get ingress -A | grep -i bulwark
|
||||
kubectl get ingress <bulwark-ingress-name> -n <bulwark-ns> -o yaml
|
||||
|
||||
# List available storage classes and ingress classes:
|
||||
kubectl get sc
|
||||
kubectl get ingressclass
|
||||
kubectl get clusterissuer # cert-manager issuers (if used)
|
||||
```
|
||||
|
||||
Then edit if they differ from the defaults below:
|
||||
|
||||
| Value | Default in manifests | File to edit |
|
||||
|-------|----------------------|--------------|
|
||||
| StorageClass | `microk8s-hostpath` | `pvc.yaml` (all 4) |
|
||||
| IngressClass | `public` | `ingress.yaml` |
|
||||
| cert-manager issuer | `letsencrypt-prod` | `ingress.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploy (copy-paste, in order)
|
||||
|
||||
```bash
|
||||
cd deploy/k8s
|
||||
|
||||
# a) Namespace
|
||||
kubectl apply -f namespace.yaml
|
||||
|
||||
# b) Image-pull secret — the GHCR package is private.
|
||||
# Use a GitHub PAT (classic) with the read:packages scope.
|
||||
kubectl create secret docker-registry ghcr-pull \
|
||||
--namespace vncmail \
|
||||
--docker-server=ghcr.io \
|
||||
--docker-username=brvncde-dotcom \
|
||||
--docker-password='<GITHUB_PAT_read:packages>' \
|
||||
--docker-email=br@vnc.biz
|
||||
|
||||
# c) App config secret — copy the template, set a real SESSION_SECRET, apply.
|
||||
cp secret.example.yaml secret.yaml
|
||||
# edit secret.yaml: SESSION_SECRET: "$(openssl rand -base64 32)"
|
||||
kubectl apply -f secret.yaml
|
||||
|
||||
# d) Everything else (PVCs, Deployment, Service, Ingress)
|
||||
kubectl apply -k .
|
||||
```
|
||||
|
||||
> Alternative to (b): make the GHCR package public
|
||||
> (GitHub → Packages → vncmail-plus-dev → Package settings → Change visibility),
|
||||
> then delete the `imagePullSecrets:` block from `deployment.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Verify
|
||||
|
||||
```bash
|
||||
kubectl -n vncmail rollout status deploy/vncmail-plus # -> successfully rolled out
|
||||
kubectl -n vncmail get pods,pvc,ingress
|
||||
|
||||
# DNS: point vncmail.sandbox.vnc.de at the same ingress IP as bulwark.sandbox.vnc.de.
|
||||
# cert-manager issues TLS once DNS resolves. Then:
|
||||
curl -sI https://vncmail.sandbox.vnc.de/api/health # -> HTTP/2 200
|
||||
```
|
||||
|
||||
Open `https://vncmail.sandbox.vnc.de` and log in with a **full** email address
|
||||
(e.g. `bernd.rodler@sandbox.vnc.de`) — Stalwart authenticates the full email, not
|
||||
a bare username.
|
||||
|
||||
---
|
||||
|
||||
## 5. Update to a new build
|
||||
|
||||
```bash
|
||||
# CI rebuilds ghcr.io/brvncde-dotcom/vncmail-plus-dev on every push to `dev`.
|
||||
kubectl -n vncmail rollout restart deploy/vncmail-plus # pulls :latest (imagePullPolicy: Always)
|
||||
|
||||
# Production: pin a digest instead of :latest so rollouts are deterministic.
|
||||
kubectl -n vncmail set image deploy/vncmail-plus \
|
||||
vncmail-plus=ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>
|
||||
```
|
||||
|
||||
Rollback: `kubectl -n vncmail rollout undo deploy/vncmail-plus`
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---------|-------------|
|
||||
| Pod `ImagePullBackOff` | `ghcr-pull` secret missing/expired, or package still private. Recreate the secret (§3b) or make the package public. |
|
||||
| Pod `CrashLoopBackOff`, logs show `EACCES`/permission on `/app/data` | Volume not writable by uid 1001. `securityContext.fsGroup: 1001` is set in `deployment.yaml` — keep it; some storage drivers also need it on the PVC. |
|
||||
| PVC stuck `Pending` | Wrong `storageClassName` in `pvc.yaml`. Set it to one from `kubectl get sc`. |
|
||||
| Ingress has no address / no cert | Wrong `ingressClassName` or cert issuer. Match bulwark's (§2). Check `kubectl -n vncmail describe ingress vncmail-plus`. |
|
||||
| Login shows "Ein Fehler ist aufgetreten" | Use the **full** email (`user@sandbox.vnc.de`), not a bare username. |
|
||||
| Can't reach Stalwart | Check `JMAP_SERVER_URL` in the secret = `https://stalwart.sandbox.vnc.de`. |
|
||||
@@ -0,0 +1,479 @@
|
||||
# VNC internal CA — EJBCA Community on microk8s
|
||||
|
||||
Runbook for `A-01` / `A-06`. Issues 1-year S/MIME certificates for VNCmail+.
|
||||
|
||||
You run every command here. Claude wrote the manifests and cannot reach the
|
||||
cluster (no kubeconfig on the authoring machine), and the root-key ceremony in
|
||||
§3 **must not** be automated by an agent — the entire value of an offline root is
|
||||
that its private key never exists on a machine that runs services or tooling.
|
||||
|
||||
---
|
||||
|
||||
## 0. One decision to make before you type anything
|
||||
|
||||
**Name the root for the organisation, not the environment.**
|
||||
|
||||
You asked for sandbox first with the ability to promote to `vncmail` at any time.
|
||||
The way that stays cheap is a single root, generated once, with *per-environment
|
||||
intermediates* underneath it:
|
||||
|
||||
```
|
||||
VNC Root CA R1 offline · 15y · RSA 4096 · pathlen:1
|
||||
├─ VNC S/MIME Issuing CA Sandbox R1 in-cluster · 5y · RSA 4096 · pathlen:0 → *@sandbox.vnc.de
|
||||
└─ VNC S/MIME Issuing CA R1 in-cluster · 5y · RSA 4096 · pathlen:0 → *@vncmail.de (later)
|
||||
```
|
||||
|
||||
Promotion is then "issue a second intermediate from the same root" — a one-hour
|
||||
ceremony. The trust anchor you distribute to laptops, phones and partners does
|
||||
not change, and certificates already issued keep validating.
|
||||
|
||||
The alternative — a throwaway `VNC Sandbox Root` — means that on promotion you
|
||||
redistribute a new trust anchor to every device and every external party who
|
||||
ever verified one of your signatures. That is the expensive path, and it is only
|
||||
visible as expensive later.
|
||||
|
||||
So: **generate the root at prod grade, once, now**, even though the first
|
||||
intermediate only serves `@sandbox.vnc.de`. The extra cost today is choosing a
|
||||
better passphrase and a safe to keep the USB key in.
|
||||
|
||||
> RSA 4096 rather than an elliptic curve throughout, deliberately. ECDSA S/MIME
|
||||
> is still poorly handled by older Outlook and by several mobile clients, and
|
||||
> S/MIME interop failures are silent — the recipient sees a broken signature, not
|
||||
> an error you get told about. Pay the key-size cost for interop you can't test.
|
||||
|
||||
---
|
||||
|
||||
## 1. Install
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/k8s/ca/namespace.yaml
|
||||
```
|
||||
|
||||
Fill in and apply the secret out-of-band (never commit real values):
|
||||
|
||||
```bash
|
||||
cp deploy/k8s/ca/secret.example.yaml /tmp/ca-secret.yaml && $EDITOR /tmp/ca-secret.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f /tmp/ca-secret.yaml && shred -u /tmp/ca-secret.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/ca/
|
||||
```
|
||||
|
||||
First boot builds the EJBCA schema and takes several minutes. Watch it rather
|
||||
than assuming it hung:
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca logs -f deploy/ejbca
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca get pods -w
|
||||
```
|
||||
|
||||
### Verify before going further
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca exec deploy/ejbca -- curl -sf http://localhost:8080/ejbca/publicweb/healthcheck/ejbcahealth && echo OK
|
||||
```
|
||||
|
||||
If the manifests' env-var names have drifted from the image tag you pulled, this
|
||||
is where it shows up — EJBCA will start but fail to bind its datasource. Check
|
||||
the documented variables for your tag before editing anything else:
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca logs deploy/ejbca | grep -iE "datasource|jdbc|database"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Get administrative access
|
||||
|
||||
EJBCA's admin web requires a client certificate. On first boot the container
|
||||
enrols a `SuperAdmin` and writes a PKCS#12 inside the pod.
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca exec deploy/ejbca -- find / -name "*.p12" -newermt "-1 day" 2>/dev/null
|
||||
```
|
||||
|
||||
Copy it out, import it into your browser, then reach the admin web by
|
||||
port-forward — it is not exposed through any ingress and must not be:
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca port-forward deploy/ejbca 8443:8443
|
||||
```
|
||||
|
||||
Then open `https://localhost:8443/ejbca/adminweb`.
|
||||
|
||||
> If the container did not create a SuperAdmin (behaviour differs by tag), use
|
||||
> the CLI inside the pod instead:
|
||||
> `kubectl -n vnc-ca exec -it deploy/ejbca -- /opt/keyfactor/bin/ejbca.sh ra addendentity ...`
|
||||
> followed by `setclearpwd` and a browser enrolment against
|
||||
> `https://localhost:8443/ejbca/ra/`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Root ceremony — you, offline, once
|
||||
|
||||
Do this on a machine that is **not** this cluster and **not** your daily laptop
|
||||
if you can manage it. A live USB session on a machine with networking physically
|
||||
off is enough for a sandbox-grade start; the point is that the root key never
|
||||
touches a host that runs services.
|
||||
|
||||
Everything below happens in one directory that you will destroy at the end.
|
||||
|
||||
**3.1 Prepare the config.** Save as `root.cnf`:
|
||||
|
||||
```ini
|
||||
[ req ]
|
||||
default_md = sha256
|
||||
prompt = no
|
||||
distinguished_name = dn
|
||||
x509_extensions = root_ext
|
||||
|
||||
[ dn ]
|
||||
C = CH
|
||||
O = VNC AG
|
||||
CN = VNC Root CA R1
|
||||
|
||||
[ root_ext ]
|
||||
basicConstraints = critical,CA:TRUE,pathlen:1
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
subjectKeyIdentifier = hash
|
||||
|
||||
# --- used in 3.4 to sign the intermediate CSR ---
|
||||
[ ca ]
|
||||
default_ca = CA_root
|
||||
|
||||
[ CA_root ]
|
||||
new_certs_dir = .
|
||||
database = index.txt
|
||||
serial = serial
|
||||
private_key = root.key
|
||||
certificate = root.crt
|
||||
default_md = sha256
|
||||
policy = policy_any
|
||||
crl = root.crl
|
||||
default_crl_days = 365
|
||||
unique_subject = no
|
||||
|
||||
[ policy_any ]
|
||||
countryName = optional
|
||||
organizationName = optional
|
||||
organizationalUnitName = optional
|
||||
commonName = supplied
|
||||
|
||||
[ int_ext ]
|
||||
basicConstraints = critical,CA:TRUE,pathlen:0
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
subjectKeyIdentifier = hash
|
||||
authorityKeyIdentifier = keyid:always
|
||||
# Revocation pointers for the INTERMEDIATE itself, served by the root's CRL.
|
||||
crlDistributionPoints = URI:http://ca.sandbox.vnc.de/ejbca/publicweb/crls/root.crl
|
||||
```
|
||||
|
||||
`pathlen:1` on the root and `pathlen:0` on the intermediate together mean the
|
||||
intermediate can issue end-entity certificates and nothing else. It cannot mint
|
||||
a further CA even if its key is stolen — that limits a compromise to "revoke one
|
||||
intermediate" instead of "the whole hierarchy is untrustworthy".
|
||||
|
||||
**3.2 Generate the root key.** You will be asked for a passphrase. Generate it
|
||||
with a password manager, minimum 24 random characters, and record where it lives
|
||||
*before* you type it — a root key whose passphrase is lost is a hierarchy you
|
||||
have to rebuild.
|
||||
|
||||
```bash
|
||||
openssl genrsa -aes256 -out root.key 4096
|
||||
```
|
||||
|
||||
**3.3 Self-sign the root.** 15 years, so it outlives several intermediate
|
||||
rotations and you do the ceremony once:
|
||||
|
||||
```bash
|
||||
openssl req -new -x509 -config root.cnf -key root.key -sha256 -days 5480 -out root.crt
|
||||
```
|
||||
|
||||
```bash
|
||||
openssl x509 -in root.crt -noout -text | sed -n '1,25p'
|
||||
```
|
||||
|
||||
Confirm in that output: `CA:TRUE, pathlen:1`, `Key Usage: Certificate Sign, CRL Sign`,
|
||||
and a 15-year validity window. If `basicConstraints` is missing the root is
|
||||
useless — the config's `x509_extensions` did not apply.
|
||||
|
||||
**3.4 Sign the intermediate.** EJBCA generates the intermediate key *inside the
|
||||
cluster* and hands you a CSR; the intermediate's private key never leaves EJBCA
|
||||
and never appears in this directory.
|
||||
|
||||
In the admin web: **CA Functions → Certificate Authorities → Create CA**
|
||||
- Name: `VNC S/MIME Issuing CA Sandbox R1`
|
||||
- Subject DN: `CN=VNC S/MIME Issuing CA Sandbox R1,O=VNC AG,C=CH`
|
||||
- Crypto Token: create a new soft token, PIN = `EJBCA_CRYPTO_TOKEN_PIN` from your secret
|
||||
- Key: RSA 4096, signing algorithm SHA256WithRSA
|
||||
- **Signed By: External CA** ← this is what makes it emit a CSR instead of self-signing
|
||||
- Validity: `5y`
|
||||
- CRL Expire Period: `1d`, CRL Overlap: `10m`
|
||||
- Default CRL Distribution Point: `http://ca.sandbox.vnc.de/ejbca/publicweb/crls/search.cgi?iHash=...` (EJBCA fills the hash — take what it offers)
|
||||
|
||||
Save, download the CSR, move it to the offline machine, then:
|
||||
|
||||
```bash
|
||||
touch index.txt && echo 1000 > serial
|
||||
```
|
||||
|
||||
```bash
|
||||
openssl ca -config root.cnf -extensions int_ext -days 1825 -notext -in sandbox-issuing.csr -out sandbox-issuing.crt
|
||||
```
|
||||
|
||||
**3.5 Issue the root CRL.** Do this now, in the same ceremony — not later. A root
|
||||
that has never published a CRL cannot revoke a compromised intermediate, and you
|
||||
will not want to bring the root key out under incident pressure just to
|
||||
discover the procedure doesn't work:
|
||||
|
||||
```bash
|
||||
openssl ca -config root.cnf -gencrl -out root.crl
|
||||
```
|
||||
|
||||
```bash
|
||||
openssl crl -in root.crl -noout -text | head -12
|
||||
```
|
||||
|
||||
**3.6 Take the outputs off, then destroy the directory.** Off the machine:
|
||||
`root.crt`, `root.crl`, `sandbox-issuing.crt`, and `root.key` (to encrypted
|
||||
storage, two copies, two physical locations).
|
||||
|
||||
```bash
|
||||
shred -u root.key && rm -rf ./*
|
||||
```
|
||||
|
||||
The root key comes out of the safe for exactly three reasons: signing a new
|
||||
intermediate (promotion to `vncmail.de`), refreshing the root CRL before it
|
||||
expires (annually — put it in a calendar now), or revoking an intermediate.
|
||||
|
||||
**3.7 Import the chain back into EJBCA.** Admin web → the CA you created →
|
||||
**Import CA certificate**, upload `root.crt` then `sandbox-issuing.crt`. The CA
|
||||
status must move to `Active`. Publish `root.crl` so the URL in the
|
||||
intermediate's CDP actually resolves.
|
||||
|
||||
---
|
||||
|
||||
## 4. Certificate profile — 1-year S/MIME
|
||||
|
||||
**Certificate Profiles → Add** → `VNC S/MIME 1y`, type *End Entity*.
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---|---|---|
|
||||
| Validity | `1y` | your decision |
|
||||
| Key algorithms | RSA 2048, 3072, 4096 | 2048 floor for interop; no ECDSA yet (§0) |
|
||||
| Key Usage | `digitalSignature`, `keyEncipherment` | signing **and** decryption need both |
|
||||
| Extended Key Usage | `emailProtection` | critical — see below |
|
||||
| Subject Alternative Name | `rfc822Name`, **required** | this is the authoritative address |
|
||||
| Basic Constraints | CA:FALSE, critical | |
|
||||
| CRL Distribution Point | use CA default | |
|
||||
| OCSP Service Locator (AIA) | `http://ca.sandbox.vnc.de/ejbca/publicweb/status/ocsp` | |
|
||||
| Allow key recovery | **on** | see §7 |
|
||||
| Allow subject DN override by CSR | **OFF** | load-bearing, see below |
|
||||
| Allow extension override by CSR | **OFF** | load-bearing, see below |
|
||||
| Allow subject alt name override by CSR | **OFF** | load-bearing, see below |
|
||||
|
||||
**The three override settings must be OFF, and this is the single most important
|
||||
line in this document.**
|
||||
|
||||
The enrolment route deliberately does *not* inspect the CSR to police what it
|
||||
asks for. It doesn't need to: the route supplies the subject and the
|
||||
`rfc822Name` SAN itself, from addresses Stalwart confirmed the account may send
|
||||
from, and the CSR contributes only a public key plus proof the requester holds
|
||||
the matching private key.
|
||||
|
||||
That reasoning is only sound while EJBCA ignores the CSR's own subject and
|
||||
extensions. Turn any of these overrides on and a hand-crafted CSR claiming
|
||||
`rfc822Name=ceo@vnc.de` gets exactly that certificate — no code change, no
|
||||
alert, and the enrolment route still looks correct in review. It is a
|
||||
one-checkbox path from "authenticated users get certificates for their own
|
||||
addresses" to "authenticated users get certificates for anyone's address".
|
||||
|
||||
Verify it rather than trusting the profile screen, once the route is live:
|
||||
|
||||
```bash
|
||||
openssl req -new -key /tmp/t.key -subj "/CN=Impostor" -addext "subjectAltName=email:ceo@vnc.de" -out /tmp/t.csr
|
||||
```
|
||||
|
||||
Submit that CSR through the enrolment route as an ordinary user. The certificate
|
||||
that comes back must carry **your own** address, not `ceo@vnc.de`.
|
||||
|
||||
Two of these carry real weight:
|
||||
|
||||
**`emailProtection` EKU, and only that.** A certificate with no EKU is treated by
|
||||
some clients as valid for *anything* — TLS server auth included. Constrain it.
|
||||
|
||||
**`rfc822Name` SAN required.** Modern clients bind the sender address from the
|
||||
SAN, not the `emailAddress` DN attribute. Our forked plugin's fix-1 check
|
||||
(`signerEmailMatch`, which refuses to auto-import a signer cert whose address
|
||||
doesn't match the `From` header) now reads the address the same way clients do —
|
||||
SAN first, and matched against *every* address the certificate carries. If EJBCA
|
||||
issues certificates without an `rfc822Name` SAN, that check fails closed and
|
||||
encryption silently never becomes available.
|
||||
|
||||
Populating the DN `emailAddress` attribute as well, for old Outlook, is safe —
|
||||
but only as of finding 11. Until then the plugin read the DN attribute *in
|
||||
preference to* the SAN and compared only the first address it found, so an EJBCA
|
||||
certificate with both fields populated would have reported every genuine
|
||||
signature as "signer ≠ From" and blocked the import. Covered now by
|
||||
`vnc/plugins/smime/verify-address-binding.mjs`.
|
||||
|
||||
**End Entity Profiles → Add** → `VNC S/MIME User`:
|
||||
- Default Certificate Profile: `VNC S/MIME 1y`; available: the same only
|
||||
- Subject DN: `CN` required + modifiable, `O=VNC AG` and `C=CH` fixed
|
||||
- Subject Alt Name: `rfc822Name` required, **and tick "Use entity email field"**
|
||||
- Default CA: `VNC S/MIME Issuing CA Sandbox R1`
|
||||
|
||||
---
|
||||
|
||||
## 5. RA credential for the enrolment route
|
||||
|
||||
The webmail server — not the browser — calls the REST API. It needs its own
|
||||
client certificate with *only* the authority to enrol end entities.
|
||||
|
||||
**5.1** Create a certificate profile `VNC RA Client` (End Entity, EKU
|
||||
`clientAuth`, validity `1y`) and enrol one entity `CN=vncmail-ra-sandbox`
|
||||
against it. Download as PKCS#12.
|
||||
|
||||
**5.2** Restrict it. **System Functions → Administrator Roles → Add** →
|
||||
`VNCmail RA (sandbox)`:
|
||||
|
||||
| Rule | Access |
|
||||
|---|---|
|
||||
| `/ca_functionality/create_certificate` | Allow |
|
||||
| `/ca/VNC S/MIME Issuing CA Sandbox R1` | Allow |
|
||||
| `/endentityprofilesrules/VNC S/MIME User/**` | Allow |
|
||||
| `/ra_functionality/revoke_end_entity` | Allow |
|
||||
| everything else | **not granted** |
|
||||
|
||||
Match by the certificate's serial + issuer DN, not by CN. Do **not** give this
|
||||
role `/administrator` or any `/system_functionality` rule: this credential lives
|
||||
on an internet-facing pod, and the blast radius of it leaking should be "issue
|
||||
and revoke S/MIME certs under one profile", not "reconfigure the CA".
|
||||
|
||||
**5.3** Load it into the webmail namespace:
|
||||
|
||||
```bash
|
||||
kubectl -n vncmail create secret generic smime-ra \
|
||||
--from-file=client.p12=./vncmail-ra-sandbox.p12 \
|
||||
--from-literal=client-password='<p12 passphrase>' \
|
||||
--from-file=ca-chain.pem=./chain.pem
|
||||
```
|
||||
|
||||
`chain.pem` is `sandbox-issuing.crt` followed by `root.crt`. The enrolment route
|
||||
pins this chain when it connects to EJBCA on 8443 — it does not trust the public
|
||||
root store, so EJBCA's self-signed server certificate (`TLS_SETUP_ENABLED=simple`)
|
||||
is correct and expected here.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verify the network policy actually enforces
|
||||
|
||||
Applying a NetworkPolicy on a CNI that doesn't implement it succeeds silently
|
||||
and protects nothing. Prove it:
|
||||
|
||||
```bash
|
||||
kubectl -n default run np-probe --rm -it --image=curlimages/curl --restart=Never -- \
|
||||
curl -sS -m 5 -k https://ejbca.vnc-ca.svc.cluster.local:8443/ejbca/ejbca-rest-api/v1/ca
|
||||
```
|
||||
|
||||
This **must** time out or be refused. If it returns anything HTTP-shaped —
|
||||
including a `401` — the policy is not being enforced and the REST API is exposed
|
||||
cluster-wide. Check your CNI before continuing:
|
||||
|
||||
```bash
|
||||
kubectl -n kube-system get pods | grep -iE "calico|cilium|flannel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Key recovery is not optional here
|
||||
|
||||
S/MIME differs from TLS in a way that has bitten every organisation that
|
||||
deployed it without thinking about this: **if a user loses their private key,
|
||||
every message ever encrypted to them is permanently unreadable.** Not
|
||||
inconvenient — gone. Re-issuing a certificate does not help, because the old
|
||||
messages were encrypted to the old key.
|
||||
|
||||
So `Allow key recovery` in §4 is deliberate, and it is a real trade-off:
|
||||
|
||||
- **on** — EJBCA escrows the decryption key. Lost laptop is recoverable. But the
|
||||
CA database now contains material that decrypts users' mail, so §8 backup
|
||||
handling and the §5 role restrictions become load-bearing, and the escrow is
|
||||
something you must be able to explain to a user asking whether their mail is
|
||||
end-to-end encrypted. It is, from the wire's perspective; it is not, from the
|
||||
CA operator's.
|
||||
- **off** — nobody but the user can ever read their mail, and a lost device is
|
||||
permanent data loss with no recourse.
|
||||
|
||||
For a corporate deployment where mail is a business record, escrow on is the
|
||||
defensible choice, and it's what §4 sets. Decide this consciously — it is far
|
||||
cheaper to turn on now than to explain later why three years of mail is gone.
|
||||
|
||||
If you keep it on, use a separate key-recovery role with two-person approval
|
||||
rather than folding that authority into the RA credential.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backup
|
||||
|
||||
`ejbca-db-data` contains the intermediate CA private key and — per §7 — escrowed
|
||||
user decryption keys. A dump of it is equivalent to the CA itself.
|
||||
|
||||
```bash
|
||||
kubectl -n vnc-ca exec deploy/ejbca-db -- sh -c \
|
||||
'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction ejbca' \
|
||||
| gzip > ejbca-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
Encrypt before it leaves your machine — an unencrypted CA dump in object storage
|
||||
is the whole hierarchy:
|
||||
|
||||
```bash
|
||||
gpg --symmetric --cipher-algo AES256 ejbca-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
Then to the shared R2 bucket (`vnc-backups1`) and **delete the plaintext**.
|
||||
Restore-test it once, now, against a scratch namespace — an untested CA backup is
|
||||
a belief, not a backup.
|
||||
|
||||
Not in this backup, by design and stored separately: the offline root key
|
||||
(§3.6), `EJBCA_CRYPTO_TOKEN_PIN`, and the RA PKCS#12 passphrase.
|
||||
|
||||
---
|
||||
|
||||
## 9. Promotion to production
|
||||
|
||||
Nothing here is thrown away. Same root, new intermediate:
|
||||
|
||||
1. Bring `root.key` out of the safe; repeat §3.4–3.6 for
|
||||
`CN=VNC S/MIME Issuing CA R1` — sign it with the **same root**.
|
||||
2. Duplicate the §4 profiles as `VNC S/MIME 1y (prod)` bound to the new CA.
|
||||
3. Fresh RA credential and role for the prod webmail namespace (§5). Never share
|
||||
the sandbox one across environments.
|
||||
4. Point the prod CDP/AIA at a stable production hostname. Those URLs are baked
|
||||
into every certificate for its full year, so get the hostname right *before*
|
||||
the first issuance.
|
||||
|
||||
The trust anchor on user devices does not change, and sandbox-issued
|
||||
certificates keep validating.
|
||||
|
||||
## 10. SwissSign (P7, deferred)
|
||||
|
||||
The point of the `CaProvider` interface on the application side is that this
|
||||
whole document becomes one implementation of it. Moving to SwissSign-issued
|
||||
certificates — for ZertES/eIDAS-qualified signatures that external parties
|
||||
validate without installing anything — is then a second implementation plus an
|
||||
identity-verification step, not a rewrite of the enrolment flow.
|
||||
|
||||
What survives unchanged: in-browser key generation, CSR construction, the
|
||||
enrolment route, storage, sign/encrypt/decrypt, the UI.
|
||||
What changes: who signs the CSR, and the fact that a human must prove their
|
||||
identity before a qualified certificate is issued — which is a process
|
||||
requirement, not a code one.
|
||||
@@ -0,0 +1,114 @@
|
||||
# EJBCA Community Edition.
|
||||
#
|
||||
# VERIFY THE ENV CONTRACT BEFORE YOU TRUST THIS FILE. EJBCA's container
|
||||
# configuration has changed across releases, so pin a tag and check its
|
||||
# documented variables rather than assuming these carry over:
|
||||
# docker run --rm keyfactor/ejbca-ce:<tag> cat /opt/keyfactor/bin/start.sh | head -60
|
||||
# The shape below (external MariaDB, two ports, healthcheck path) is stable; the
|
||||
# individual variable names are the part most likely to drift.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ejbca
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: ejbca
|
||||
ports:
|
||||
# 8080 — plain HTTP, NO client-certificate authentication. Only the public
|
||||
# web is served here: CRL distribution and the OCSP responder. This is the
|
||||
# only port the public ingress touches.
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
# 8443 — HTTPS with mandatory client-certificate auth. Admin web AND the
|
||||
# REST API. Never exposed through an ingress; reachable only from inside the
|
||||
# cluster (the enrolment route) or via `kubectl port-forward` (you, doing
|
||||
# administration). See networkpolicy.yaml.
|
||||
- name: https
|
||||
port: 8443
|
||||
targetPort: 8443
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ejbca
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ejbca
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ejbca
|
||||
spec:
|
||||
# EJBCA needs the DB reachable before WildFly deploys its datasource.
|
||||
initContainers:
|
||||
- name: wait-for-db
|
||||
image: mariadb:11.4
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until mariadb-admin ping -h ejbca-db --silent; do
|
||||
echo "waiting for ejbca-db..."; sleep 3
|
||||
done
|
||||
containers:
|
||||
- name: ejbca
|
||||
# Pin an explicit tag. `latest` on a CA is how you get an unplanned
|
||||
# schema migration during an incident.
|
||||
image: keyfactor/ejbca-ce:9.1.1
|
||||
env:
|
||||
- name: DATABASE_JDBC_URL
|
||||
value: jdbc:mariadb://ejbca-db:3306/ejbca?characterEncoding=UTF-8
|
||||
- name: DATABASE_USER
|
||||
valueFrom:
|
||||
secretKeyRef: { name: ejbca-db, key: MARIADB_USER }
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: { name: ejbca-db, key: MARIADB_PASSWORD }
|
||||
# Lets EJBCA generate its own server TLS keypair on first boot. The
|
||||
# REST/admin listener is cluster-internal and authenticated by
|
||||
# CLIENT certificate, so a self-signed server cert here is fine —
|
||||
# our enrolment route pins the CA chain explicitly rather than
|
||||
# trusting the public roots. Do not "fix" this with cert-manager
|
||||
# without also updating that pin.
|
||||
- name: TLS_SETUP_ENABLED
|
||||
value: "simple"
|
||||
- name: LOG_LEVEL_APP
|
||||
value: INFO
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
- name: https
|
||||
containerPort: 8443
|
||||
# First boot builds the schema and can take minutes. A tight
|
||||
# startupProbe budget here will CrashLoop a CA that is merely slow.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /ejbca/publicweb/healthcheck/ejbcahealth
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ejbca/publicweb/healthcheck/ejbcahealth
|
||||
port: 8080
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ejbca/publicweb/healthcheck/ejbcahealth
|
||||
port: 8080
|
||||
periodSeconds: 30
|
||||
failureThreshold: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 2Gi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
@@ -0,0 +1,51 @@
|
||||
# PUBLIC surface of the CA — revocation checking ONLY.
|
||||
#
|
||||
# Two prefixes are routed and nothing else. Not the admin web, not the REST API,
|
||||
# not the public enrolment pages (/ejbca/ra/, /ejbca/enrol/). Anything else at
|
||||
# this host 404s because no rule matches it.
|
||||
#
|
||||
# WHY THIS MUST BE PUBLIC AT ALL: every certificate this CA issues carries the
|
||||
# CRL Distribution Point and OCSP responder URL *inside* it, and those URLs are
|
||||
# fetched by whoever is validating the certificate. For internal-only S/MIME that
|
||||
# could stay private — but the moment a signed message leaves the building, the
|
||||
# recipient's mail client resolves these URLs from the outside. They also become
|
||||
# permanent: certificates already issued keep pointing here for their full year,
|
||||
# so this hostname cannot be changed casually. Fix the hostname before the first
|
||||
# real issuance, not after.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vnc-ca-public
|
||||
namespace: vnc-ca
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# Revocation data is public by design and must be cacheable — an OCSP
|
||||
# responder that is slow or down makes every client either hang or
|
||||
# soft-fail open, and soft-fail-open is the same as no revocation at all.
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "20"
|
||||
spec:
|
||||
ingressClassName: public
|
||||
tls:
|
||||
- hosts:
|
||||
- ca.sandbox.vnc.de
|
||||
secretName: vnc-ca-public-tls
|
||||
rules:
|
||||
- host: ca.sandbox.vnc.de
|
||||
http:
|
||||
paths:
|
||||
# CRL download — http://ca.sandbox.vnc.de/ejbca/publicweb/crls/...
|
||||
- path: /ejbca/publicweb/crls
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ejbca
|
||||
port:
|
||||
number: 8080
|
||||
# OCSP responder — POST target for status queries.
|
||||
- path: /ejbca/publicweb/status/ocsp
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ejbca
|
||||
port:
|
||||
number: 8080
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
# secret.example.yaml is deliberately NOT listed. Apply your filled-in copy
|
||||
# out-of-band so real passwords never pass through a file in this repo.
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- mariadb.yaml
|
||||
- ejbca.yaml
|
||||
- ingress.yaml
|
||||
- networkpolicy.yaml
|
||||
|
||||
# Order matters on a cold cluster: the namespace and the secret must exist before
|
||||
# the workloads. kustomize sorts by kind and handles the namespace; the secret is
|
||||
# on you. See README.md § Install.
|
||||
@@ -0,0 +1,92 @@
|
||||
# MariaDB for EJBCA.
|
||||
#
|
||||
# WHY A REAL DATABASE AND NOT THE EMBEDDED H2: the EJBCA container can run on an
|
||||
# internal H2 database for a quick look, but H2 is explicitly not supported for
|
||||
# anything you intend to keep. Since this sandbox CA has to be *promotable* to
|
||||
# production (your decision: "sandbox first and upgrade later"), the database is
|
||||
# the one thing that must not need re-platforming later — every certificate ever
|
||||
# issued, every revocation, and the intermediate CA key all live in here.
|
||||
#
|
||||
# THIS PVC IS THE CROWN JEWELS. See README.md § Backup.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ejbca-db-data
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
# microk8s default. Confirm with `kubectl get sc` and match your cluster.
|
||||
storageClassName: microk8s-hostpath
|
||||
resources:
|
||||
requests:
|
||||
storage: 8Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ejbca-db
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: ejbca-db
|
||||
ports:
|
||||
- name: mysql
|
||||
port: 3306
|
||||
targetPort: 3306
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ejbca-db
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
replicas: 1
|
||||
# Never run two replicas against one RWO volume, and never roll a new pod up
|
||||
# while the old one still holds the data directory.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ejbca-db
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ejbca-db
|
||||
spec:
|
||||
containers:
|
||||
- name: mariadb
|
||||
image: mariadb:11.4
|
||||
args:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
# EJBCA is case-sensitive about its own table names.
|
||||
- --lower_case_table_names=0
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: ejbca-db
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/mysql
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["healthcheck.sh", "--connect"]
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: ejbca-db-data
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vnc-ca
|
||||
labels:
|
||||
# The CA is deliberately in its own namespace, NOT in `vncmail`. The webmail
|
||||
# pod is internet-facing; the CA signs certificates. A compromise of the
|
||||
# former must not be a compromise of the latter, and namespace-scoped RBAC
|
||||
# plus the NetworkPolicy in networkpolicy.yaml are what enforce that.
|
||||
app.kubernetes.io/name: vnc-ca
|
||||
app.kubernetes.io/part-of: vncmail-plus
|
||||
@@ -0,0 +1,82 @@
|
||||
# Default-deny ingress for the CA namespace, then three narrow allowances.
|
||||
#
|
||||
# Without this, the REST API on 8443 is reachable from every pod in the cluster.
|
||||
# It is still client-cert authenticated, so this is defence in depth rather than
|
||||
# the only control — but "the only thing standing between any compromised pod and
|
||||
# a certificate factory is one TLS handshake" is not a position to be in.
|
||||
#
|
||||
# PREREQUISITE: microk8s needs a CNI that enforces NetworkPolicy. The default
|
||||
# (Calico) does. If you are on flannel without a policy plugin these objects
|
||||
# apply cleanly and silently enforce NOTHING — verify with the test in
|
||||
# README.md § Verify the network policy rather than assuming.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Ingress]
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-public-web-from-ingress
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: ejbca
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
# Port 8080 (CRL/OCSP) from the ingress controller only.
|
||||
# VERIFY THE NAMESPACE: microk8s' nginx addon has historically used
|
||||
# `ingress`, `kube-system`, and `ingress-nginx` depending on version.
|
||||
# kubectl get pods -A | grep -i ingress
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: ingress
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-rest-from-vncmail
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: ejbca
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
# Port 8443 (REST API) from the webmail namespace only. This is the
|
||||
# enrolment route calling the CA with its RA client certificate.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: vncmail
|
||||
ports:
|
||||
- port: 8443
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-db-from-ejbca
|
||||
namespace: vnc-ca
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: ejbca-db
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: ejbca
|
||||
ports:
|
||||
- port: 3306
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,35 @@
|
||||
# Template only — DO NOT `kubectl apply` this file and DO NOT commit real values.
|
||||
#
|
||||
# Copy to secret.yaml (gitignored), fill in, apply, then delete your local copy:
|
||||
# cp secret.example.yaml /tmp/ca-secret.yaml
|
||||
# $EDITOR /tmp/ca-secret.yaml
|
||||
# kubectl apply -f /tmp/ca-secret.yaml && shred -u /tmp/ca-secret.yaml
|
||||
#
|
||||
# Generate each password with: openssl rand -base64 24
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ejbca-db
|
||||
namespace: vnc-ca
|
||||
type: Opaque
|
||||
stringData:
|
||||
# MariaDB credentials. The EJBCA database holds the CA private keys (soft
|
||||
# crypto token, encrypted at rest by EJBCA) — treat a dump of it as
|
||||
# equivalent to the intermediate CA key itself.
|
||||
MARIADB_ROOT_PASSWORD: CHANGEME_root
|
||||
MARIADB_USER: ejbca
|
||||
MARIADB_PASSWORD: CHANGEME_ejbca
|
||||
MARIADB_DATABASE: ejbca
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ejbca-app
|
||||
namespace: vnc-ca
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Passphrase protecting EJBCA's internal soft crypto token (the one that
|
||||
# wraps the intermediate CA key). Losing this loses the intermediate.
|
||||
# Back it up somewhere that is NOT this cluster.
|
||||
EJBCA_CRYPTO_TOKEN_PIN: CHANGEME_token
|
||||
@@ -0,0 +1,87 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
namespace: vncmail
|
||||
labels:
|
||||
app: vncmail-plus
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vncmail-plus
|
||||
# RWO volumes can only mount to one pod — Recreate avoids a stuck rollout.
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: vncmail-plus
|
||||
spec:
|
||||
# The image runs as uid/gid 1001 (nextjs:nodejs) and the Dockerfile
|
||||
# chowns /app/data to 1001. fsGroup makes the mounted PVCs writable by it.
|
||||
securityContext:
|
||||
fsGroup: 1001
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
# ghcr package is private by default — see deploy/k8s/README.md to create
|
||||
# this pull secret. Delete this block if you make the package public.
|
||||
imagePullSecrets:
|
||||
- name: ghcr-pull
|
||||
containers:
|
||||
- name: vncmail-plus
|
||||
# dev image (built from the `dev` branch by CI). For production pin a
|
||||
# digest: ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>
|
||||
image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: vncmail-env
|
||||
env:
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 25
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
volumeMounts:
|
||||
- name: settings
|
||||
mountPath: /app/data/settings
|
||||
- name: admin
|
||||
mountPath: /app/data/admin
|
||||
- name: admin-state
|
||||
mountPath: /app/data/admin-state
|
||||
- name: telemetry
|
||||
mountPath: /app/data/telemetry
|
||||
volumes:
|
||||
- name: settings
|
||||
persistentVolumeClaim:
|
||||
claimName: vncmail-settings
|
||||
- name: admin
|
||||
persistentVolumeClaim:
|
||||
claimName: vncmail-admin
|
||||
- name: admin-state
|
||||
persistentVolumeClaim:
|
||||
claimName: vncmail-admin-state
|
||||
- name: telemetry
|
||||
persistentVolumeClaim:
|
||||
claimName: vncmail-telemetry
|
||||
@@ -0,0 +1,34 @@
|
||||
# Exposes VNCmail+ at vncmail.sandbox.vnc.de, alongside bulwark.sandbox.vnc.de.
|
||||
# MATCH YOUR CLUSTER — inspect the existing Bulwark ingress and copy its
|
||||
# ingressClassName + TLS/cert-manager annotations:
|
||||
# kubectl get ingress -A | grep bulwark
|
||||
# kubectl get ingress <bulwark-ingress> -n <ns> -o yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
namespace: vncmail
|
||||
annotations:
|
||||
# cert-manager issuer — set to whatever bulwark.sandbox.vnc.de uses.
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# Mail attachments can be large; raise the nginx body limit.
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
spec:
|
||||
# microk8s ingress addon class is usually "public" (nginx). Confirm with
|
||||
# `kubectl get ingressclass` and match bulwark's.
|
||||
ingressClassName: public
|
||||
tls:
|
||||
- hosts:
|
||||
- vncmail.sandbox.vnc.de
|
||||
secretName: vncmail-plus-tls
|
||||
rules:
|
||||
- host: vncmail.sandbox.vnc.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vncmail-plus
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: vncmail
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- pvc.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
# - secret.yaml # create from secret.example.yaml; not committed
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vncmail
|
||||
labels:
|
||||
app.kubernetes.io/part-of: vnclagoon-suite
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user