Compare commits
270
Commits
1.7.5
..
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 | ||
|
|
fa31933922 | ||
|
|
d3f77ef9cf | ||
|
|
5ccba83129 | ||
|
|
752e71198c | ||
|
|
2cb74c0186 | ||
|
|
9ab7339320 | ||
|
|
782974ecdb | ||
|
|
c47137fb49 | ||
|
|
38313639ed | ||
|
|
e933800792 | ||
|
|
60a1cc670c | ||
|
|
c0515001f1 | ||
|
|
e10fced28a | ||
|
|
3d36492518 | ||
|
|
94af4725b6 | ||
|
|
22418c17cf | ||
|
|
8904d724bb | ||
|
|
c7d551f185 | ||
|
|
6470fa86f0 | ||
|
|
8647d709ff | ||
|
|
b73d1b55d1 | ||
|
|
7db6fd7e24 | ||
|
|
e066698938 | ||
|
|
c3a97de62f | ||
|
|
29283282d5 | ||
|
|
db2c642d74 | ||
|
|
9110bc388f | ||
|
|
a4dc0b7b4e | ||
|
|
d384c3b553 | ||
|
|
d7a64fd9d6 | ||
|
|
06ddda688d | ||
|
|
6b74615969 | ||
|
|
0d73cb5dfb | ||
|
|
b1b09d54c8 | ||
|
|
9930d19ba4 | ||
|
|
902774eae1 | ||
|
|
1429a6fe1e | ||
|
|
d1da83a5d6 | ||
|
|
babacca482 | ||
|
|
e6aa79ed94 | ||
|
|
4d6b4b5b8e | ||
|
|
d259168bd7 | ||
|
|
97d87c44d0 | ||
|
|
396f5f7d60 | ||
|
|
9aef8bc393 | ||
|
|
e05ab48a45 | ||
|
|
14c2807f0a | ||
|
|
a099ab442a | ||
|
|
e9ac2de6cb | ||
|
|
c4a7f575c5 | ||
|
|
51c3a69be7 | ||
|
|
717b1d8397 | ||
|
|
f291480565 | ||
|
|
95b5a81924 | ||
|
|
9da27df249 | ||
|
|
b716f95a73 | ||
|
|
6c49427c7c | ||
|
|
2a41e73bf9 | ||
|
|
16daf6ea03 | ||
|
|
65cb6be8a9 | ||
|
|
2704d5dc23 | ||
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 |
@@ -9,3 +9,8 @@ scripts/
|
|||||||
TODO.md
|
TODO.md
|
||||||
*.md
|
*.md
|
||||||
!README.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_FORMAT=text
|
||||||
LOG_LEVEL=debug
|
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)
|
# 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.
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
# 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
|
# 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 issuer's public hostname resolves to an internal IP from this server.
|
||||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
# 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
|
# Session & Security
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -132,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# so the instance id and consent choice survive upgrades.
|
# so the instance id and consent choice survive upgrades.
|
||||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
# 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
|
# 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
|
# Should match your app's main background color. Default: #ffffff
|
||||||
# PWA_BACKGROUND_COLOR=#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
|
# Logos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -234,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
|||||||
# URL for the company website link on the login page.
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
|
# 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)
|
# 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).
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# 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
|
# Internationalization
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -274,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
#
|
#
|
||||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||||
# match any supported locale. Defaults to "en".
|
# 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
|
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||||
|
|
||||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||||
|
|||||||
@@ -118,3 +118,114 @@ jobs:
|
|||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
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
|
# Sibling repos
|
||||||
/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
|
||||||
|
|||||||
+149
@@ -1,5 +1,154 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
|
||||||
|
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
|
||||||
|
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
|
||||||
|
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
|
||||||
|
- **i18n**: Hebrew locale with full RTL support
|
||||||
|
- **i18n**: Slovak translation
|
||||||
|
- **i18n**: User-selectable regional date format
|
||||||
|
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
|
||||||
|
- **Mail**: Pin emails to the top of the folder list
|
||||||
|
- **Mail**: Setting to disable the tag-color row tint in the message list
|
||||||
|
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
|
||||||
|
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
|
||||||
|
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
|
||||||
|
- **Composer**: Preselect the identity of the active mailbox for new messages
|
||||||
|
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
|
||||||
|
- **Headers**: Parse Stalwart spam headers
|
||||||
|
- **Login**: Configurable logo size and hideable heading/subtitle
|
||||||
|
- **PWA**: Apple Touch icons for the iOS home screen
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
|
||||||
|
- **Mail**: Keep advanced search filters applied when switching folders (#553)
|
||||||
|
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
|
||||||
|
- **Mail**: Route keyword writes to the email's own account in unified view
|
||||||
|
- **Mail**: Render emails that set `height:100%` on a wrapper element
|
||||||
|
- **Mail**: Hide images that fail to load
|
||||||
|
- **Mail**: Storage quota not shown with Stalwart (#577)
|
||||||
|
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
|
||||||
|
- **Spam**: Fix stale folder counters and open message after spam actions
|
||||||
|
- **Composer**: Wait for in-flight attachment uploads before sending
|
||||||
|
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
|
||||||
|
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
|
||||||
|
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
|
||||||
|
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
|
||||||
|
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
|
||||||
|
- **Auth**: Keep the session when the auth server is briefly unreachable
|
||||||
|
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
|
||||||
|
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
|
||||||
|
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
|
||||||
|
- **Plugins**: Preserve the settings slot and privileged tier
|
||||||
|
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
|
||||||
|
- **Pro**: Show the Edit button on draft emails opened in a new tab
|
||||||
|
- **List**: Shift-click on the checkbox extends the selection (range)
|
||||||
|
- **CSP**: Allow external/data fonts so email webfonts render
|
||||||
|
- **Notifications**: Brand push notifications with the configured PWA icon
|
||||||
|
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
|
||||||
|
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
|
||||||
|
- **Branding**: Apply per-domain favicon override in root metadata (#585)
|
||||||
|
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
|
||||||
|
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
|
||||||
|
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
|
||||||
|
|
||||||
|
## 1.7.6 (2026-06-28)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||||
|
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||||
|
- **Mail**: Option to hide the total message count on folders (#498)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||||
|
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||||
|
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||||
|
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||||
|
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||||
|
- **Auth**: Support MFA login via the structured auth endpoint
|
||||||
|
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
## 1.7.5 (2026-06-24)
|
## 1.7.5 (2026-06-24)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+79
-33
@@ -10,13 +10,13 @@
|
|||||||
|
|
||||||
# Contributing to Bulwark Webmail
|
# 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
|
- **Get support** - real-time help with development hurdles
|
||||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
- **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:
|
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**:
|
3. **Set up environment**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.dev.example .env.local
|
||||||
# Edit .env.local with your JMAP server URL
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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**:
|
4. **Start development server**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
Then open http://localhost:3000.
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
|
||||||
Before submitting a pull request, ensure your code passes all checks:
|
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.
|
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
|
### TypeScript
|
||||||
|
|
||||||
@@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
|||||||
- Avoid `any` types when possible
|
- Avoid `any` types when possible
|
||||||
- Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
|
|
||||||
### React Components
|
### React components
|
||||||
|
|
||||||
- Use functional components with hooks
|
- Use functional components with hooks
|
||||||
- Keep components focused and single-purpose
|
- Keep components focused and single-purpose
|
||||||
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
|||||||
|
|
||||||
## Internationalization (i18n)
|
## 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
|
### Rules
|
||||||
|
|
||||||
@@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
router.push(`/${params.locale}/settings`);
|
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**:
|
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
|
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
|
4. **Update translations** if you added user-facing text
|
||||||
|
|
||||||
5. **Run all checks**:
|
5. **Run all checks**:
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck && npm run lint
|
npm run typecheck && npm run lint && npx vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Submitting
|
### Submitting
|
||||||
@@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
- Screenshots for UI changes
|
- Screenshots for UI changes
|
||||||
- Reference to any related issues
|
- Reference to any related issues
|
||||||
|
|
||||||
### Commit Message Convention
|
### Commit message convention
|
||||||
|
|
||||||
Follow the conventional commits format:
|
Follow the conventional commits format:
|
||||||
|
|
||||||
@@ -177,25 +211,37 @@ fix: resolve attachment download issue
|
|||||||
docs: update README with keyboard shortcuts
|
docs: update README with keyboard shortcuts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
webmail/
|
webmail/
|
||||||
├── app/ # Next.js App Router pages
|
├── app/ # Next.js App Router
|
||||||
│ └── [locale]/ # Locale-aware routing
|
│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
|
||||||
├── components/ # React components
|
│ ├── (main)/admin/ # Admin dashboard
|
||||||
│ ├── email/ # Email-related components
|
│ ├── (main)/setup/ # First-launch setup wizard
|
||||||
│ ├── layout/ # Layout components
|
│ ├── (sandbox)/ # Isolated plugin sandbox routes
|
||||||
│ ├── settings/ # Settings components
|
│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
|
||||||
│ └── ui/ # Reusable UI components
|
├── components/ # React components
|
||||||
├── contexts/ # React contexts
|
│ ├── email/ # Email list, viewer, composer
|
||||||
├── hooks/ # Custom React hooks
|
│ ├── calendar/ contacts/ files/ filters/ templates/
|
||||||
├── lib/ # Utilities and libraries
|
│ ├── layout/ # Sidebar, shell, navigation
|
||||||
│ └── jmap/ # JMAP client implementation
|
│ ├── settings/ # Settings panels
|
||||||
├── locales/ # Translation files
|
│ ├── plugins/ # Plugin host UI
|
||||||
│ ├── en/ # English translations
|
│ └── ui/ # Reusable primitives
|
||||||
│ └── fr/ # French translations
|
├── contexts/ # React contexts
|
||||||
└── stores/ # Zustand state stores
|
├── 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
|
## Security
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# at build time, so it cannot be changed without rebuilding.
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=$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
|
# 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
|
# Accept-Language header does not match any supported locale. Baked in at
|
||||||
# build time because next-intl wires it into client-side routing too.
|
# build time because next-intl wires it into client-side routing too.
|
||||||
|
|||||||
+111
-107
@@ -2,145 +2,149 @@
|
|||||||
|
|
||||||
## Mail
|
## Mail
|
||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
- 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 with inline expansion and an optional conversation toggle
|
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
|
||||||
- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
- 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.
|
||||||
- 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, 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.
|
||||||
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
|
||||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `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
|
- 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 and configurable send delay
|
- Scheduled send, plus a configurable delay before anything leaves the outbox
|
||||||
- Read receipts (MDN, RFC 8098)
|
- Read receipts (MDN, RFC 8098)
|
||||||
- Editable, layout-preserving quote island when replying
|
- Quoted text lands in an editable island that keeps the original layout
|
||||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
- Batch operations – multi-select, archive, delete, move, tag
|
- Multi-select for batch archive, delete, move, and tag
|
||||||
- Archive modes – direct, by year, or by month
|
- Archive directly, by year, or by month
|
||||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
||||||
- Star/unstar with configurable mark-as-read delay
|
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
- Star or unstar, with a configurable mark-as-read delay
|
||||||
- Plain-text composer mode and Reply-To support
|
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||||
- Configurable signature position (above or below quoted text) per identity
|
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||||
- 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
|
- Plain-text composer mode and Reply-To
|
||||||
- `.eml` file import via folder right-click menu
|
- 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
|
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||||
- Folder management with icon picker, subfolders, and sidebar counts
|
- Folders take an icon, nest, and show counts in the sidebar
|
||||||
- Print directly from the viewer
|
- Print from the viewer
|
||||||
- Browser history sync for back/forward navigation
|
- Browser back and forward move through mail history
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
|
|
||||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
|
||||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
|
||||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
- Recurring events edit and delete by scope: this occurrence, this and following, or all
|
||||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
|
||||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
|
||||||
- iCalendar import with preview, bulk create, and UID deduplication
|
- iCalendar import previews first, then bulk-creates, deduplicating on UID
|
||||||
- iCal / webcal subscriptions with editing and batch import
|
- iCal / webcal subscriptions, editable, with batch import
|
||||||
- Auto-generated birthday calendar from contacts
|
- A birthday calendar generated from your contacts
|
||||||
- Virtual locations (video conference URLs) as first-class event fields
|
- Virtual locations (video-conference URLs) are first-class event fields
|
||||||
- Task management with due dates, priority, and completion status
|
- Tasks with due dates, priority, and completion status
|
||||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
|
||||||
- Week numbers, event hover preview, notifications with sound picker
|
- Week numbers, hover preview, notifications with a sound picker
|
||||||
- Real-time sync via JMAP push
|
- JMAP push keeps everything in sync
|
||||||
|
|
||||||
## Contacts
|
## Contacts
|
||||||
|
|
||||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
- JMAP sync (RFC 9553 / 9610), falling back to local storage
|
||||||
- Multiple address books with drag-and-drop between books
|
- Several address books, with drag-and-drop between them
|
||||||
- Contact groups with member management
|
- Groups with member management
|
||||||
- vCard import/export (RFC 6350) with duplicate detection
|
- vCard import/export (RFC 6350) that flags duplicates
|
||||||
- Trusted senders stored in a dedicated JMAP address book
|
- Trusted senders live in their own JMAP address book
|
||||||
- Autocomplete in the composer (To / Cc / Bcc)
|
- Autocomplete on To, Cc, and Bcc
|
||||||
|
|
||||||
## Filters & Templates
|
## Filters & templates
|
||||||
|
|
||||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
- Server-side filters as 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…)
|
- 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
|
||||||
- Preserves rules authored in other clients
|
- Rules written in other clients survive the round-trip
|
||||||
- Raw Sieve editor with syntax validation
|
- Raw Sieve editor with syntax validation
|
||||||
- Vacation responder with date range scheduling
|
- A vacation responder you can schedule to a date range
|
||||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||||
|
|
||||||
## Files
|
## 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
|
- 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 and folder upload with progress tracking
|
- Streamed WebDAV PUT upload, whole folders included, with progress
|
||||||
- Dynamic upload limits based on server configuration
|
- Upload limits follow the server's own configuration
|
||||||
- Grid and list views with sorting by name, size, or date
|
- Grid or list, sorted by name, size, or date
|
||||||
- Previews for images, text, audio, and video
|
- Preview images, text, audio, and video
|
||||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
- Cut, copy, paste, duplicate; favorites; 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
|
- 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
|
- External content stays blocked until you say otherwise, and trusted senders are remembered
|
||||||
- HTML sanitization via DOMPurify
|
- HTML sanitized through DOMPurify
|
||||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
|
||||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop 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
|
- 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
|
- TOTP two-factor authentication
|
||||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
- Password and 2FA management through the Stalwart admin API
|
||||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
|
||||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
|
||||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
- Plugins are scanned for dangerous patterns and need admin approval
|
||||||
- Newsletter unsubscribe (RFC 2369)
|
- Newsletter unsubscribe (RFC 2369)
|
||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
- Split three-pane, focused list, or bottom reading pane, columns resizable
|
||||||
- Dark and light themes with intelligent email color transformation
|
- 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 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
|
- 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.
|
||||||
- Responsive desktop, tablet, and mobile layouts
|
- Layouts for desktop, tablet, and mobile
|
||||||
- Full keyboard navigation
|
- Full keyboard navigation
|
||||||
- Drag-and-drop email organization and tag assignment
|
- Drag and drop to organize mail and assign tags
|
||||||
- Interactive guided tour for new users
|
- A guided tour for first-time users
|
||||||
- Right-click context menus, toast notifications with undo
|
- Right-click menus, and toasts that offer an undo
|
||||||
- Customizable toolbar position, favicon, and login branding
|
- Toolbar position, favicon, and login branding are configurable
|
||||||
- Pinnable sidebar apps with drag-and-drop reordering
|
- Sidebar apps pin and reorder by drag
|
||||||
- Encrypted settings sync across devices
|
- Settings sync between devices, encrypted
|
||||||
- Storage quota display
|
- 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
|
## 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)
|
- 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.
|
||||||
- Account switcher with connection status and default account selection
|
- An account switcher showing connection status, and a default account
|
||||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
|
||||||
- Configurable signature position (above or below quoted text)
|
- Signature above or below the quoted text
|
||||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
|
||||||
- Shared folders across accounts
|
- 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
|
- 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.
|
||||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
- Several JMAP servers per deployment, optionally auto-picked by email domain
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- 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
|
- 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.
|
||||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
- The Stalwart admin dashboard, its policy sections collapsed into one 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
|
- 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.
|
||||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
- 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.
|
||||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
|
||||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
- An admin toggle controls 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 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
|
||||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
|
||||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
- Themes upload as ZIP bundles, and admins can enforce one
|
||||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
- 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 calendar integration
|
- Bundled plugins, including Jitsi Meet for the calendar
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
|
||||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
- 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 category-based levels
|
- Structured logging (`text` or `json`) with per-category 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
|
- 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.
|
||||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
- Docker images on GHCR, for release (`main`) and development (`dev`)
|
||||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
|
||||||
- Demo mode with fixture data – no mail server required
|
- Demo mode runs on fixture data, no mail server required
|
||||||
|
|||||||
@@ -8,26 +8,19 @@
|
|||||||
|
|
||||||
# Bulwark Webmail
|
# 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)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installer
|
## Installer
|
||||||
|
|
||||||
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
Since **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>
|
|
||||||
|
|
||||||
Point a browser at the running container and the wizard guides you through:
|
Point a browser at the running container and the wizard guides you through:
|
||||||
|
|
||||||
@@ -72,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>
|
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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
|
- **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
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **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)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick start
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -106,9 +99,9 @@ Or with Docker Compose:
|
|||||||
docker compose up -d
|
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
|
```bash
|
||||||
git clone https://github.com/bulwarkmail/webmail.git
|
git clone https://github.com/bulwarkmail/webmail.git
|
||||||
@@ -121,16 +114,20 @@ npm run build && npm start
|
|||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```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 typecheck
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npx vitest run # Unit tests
|
||||||
|
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## 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
|
```env
|
||||||
# Optional – overrides whatever the wizard writes
|
# Optional – overrides whatever the wizard writes
|
||||||
@@ -153,13 +150,28 @@ PORT=3000
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
|
OAUTH_ONLY=true # hide the username/password form entirely
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
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>
|
</details>
|
||||||
|
|
||||||
@@ -257,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
|
|||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary>Subpath / reverse proxy mount</summary>
|
<summary>Subpath / reverse proxy mount</summary>
|
||||||
|
|
||||||
@@ -273,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 .
|
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>
|
</details>
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| ------------- | ----------------------- |
|
| -------------------- | ----------------------- |
|
||||||
| `j` / `k` | Navigate between emails |
|
| `j` `↓` / `k` `↑` | Navigate between emails |
|
||||||
| `Enter` / `o` | Open email |
|
| `Enter` / `o` | Open email |
|
||||||
| `Esc` | Close / deselect |
|
| `Esc` | Close / deselect |
|
||||||
| `c` | Compose |
|
| `x` | Expand / collapse thread |
|
||||||
| `r` / `R` | Reply / Reply all |
|
| `c` | Compose |
|
||||||
| `f` | Forward |
|
| `r` / `R` `a` | Reply / Reply all |
|
||||||
| `s` | Star |
|
| `f` | Forward |
|
||||||
| `e` | Archive |
|
| `s` | Star |
|
||||||
| `#` | Delete |
|
| `e` | Archive |
|
||||||
| `/` | Search |
|
| `#` / `Del` | Delete |
|
||||||
| `?` | Show all shortcuts |
|
| `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 |
|
| **Language** | TypeScript |
|
||||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||||
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
||||||
| **Protocol** | Custom JMAP client (RFC 8620) |
|
| **Protocol** | Custom JMAP client (RFC 8620) |
|
||||||
|
| **Editor** | [Tiptap](https://tiptap.dev/) |
|
||||||
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
||||||
| **Icons** | [Lucide React](https://lucide.dev/) |
|
| **Icons** | [Lucide React](https://lucide.dev/) |
|
||||||
|
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
|
||||||
|
|
||||||
## Why Stalwart?
|
## 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`.)
|
||||||
@@ -1149,13 +1149,13 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||||
<span className="text-left">
|
<span className="text-start">
|
||||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||||
<span className="text-left">
|
<span className="text-start">
|
||||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1336,7 +1336,7 @@ export default function CalendarPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||||
!isResizing && "transition-[width] duration-300",
|
!isResizing && "transition-[width] duration-300",
|
||||||
isNarrow && cn(
|
isNarrow && cn(
|
||||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
@@ -1475,7 +1475,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop event panel */}
|
{/* Desktop event panel */}
|
||||||
{!isMobile && showEventModal && (
|
{!isMobile && showEventModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
@@ -1498,7 +1498,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop task panel */}
|
{/* Desktop task panel */}
|
||||||
{!isMobile && showTaskModal && (
|
{!isMobile && showTaskModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||||
<TaskModal
|
<TaskModal
|
||||||
key={editTask?.id ?? 'new-task'}
|
key={editTask?.id ?? 'new-task'}
|
||||||
task={editTask}
|
task={editTask}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export";
|
|||||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
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 { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
@@ -400,8 +400,8 @@ export default function ContactsPage() {
|
|||||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||||
|
|
||||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||||
void _id; void _created; void _updated;
|
void _id; void _uid; void _created; void _updated;
|
||||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await createContact(client, data);
|
await createContact(client, data);
|
||||||
@@ -513,23 +513,31 @@ export default function ContactsPage() {
|
|||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||||
// Format each member as "Name <email>" so the composer keeps the display
|
// Hand the composer a single group chip (RFC 5322 group syntax survives
|
||||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
// the string hand-off) instead of one entry per member - the chip expands
|
||||||
// email, case-insensitively; members without an email are skipped.
|
// 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 seen = new Set<string>();
|
||||||
const recipients: string[] = [];
|
const members: Array<{ name?: string; email: string }> = [];
|
||||||
for (const member of getGroupMembers(groupId)) {
|
for (const member of getGroupMembers(groupId)) {
|
||||||
const email = getContactPrimaryEmail(member).trim();
|
const email = getContactPrimaryEmail(member).trim();
|
||||||
const key = email.toLowerCase();
|
const key = email.toLowerCase();
|
||||||
if (!email || seen.has(key)) continue;
|
if (!email || seen.has(key)) continue;
|
||||||
seen.add(key);
|
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"));
|
toast.error(t("groups.no_member_emails"));
|
||||||
return;
|
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]);
|
}, [getGroupMembers, t, openComposeInApp]);
|
||||||
|
|
||||||
const handleComposeContact = useCallback((contact: ContactCard) => {
|
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||||
@@ -751,7 +759,7 @@ export default function ContactsPage() {
|
|||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
||||||
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
@@ -869,7 +877,7 @@ export default function ContactsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||||
!isSidebarResizing && "transition-[width] duration-300",
|
!isSidebarResizing && "transition-[width] duration-300",
|
||||||
isNarrow && cn(
|
isNarrow && cn(
|
||||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
@@ -937,7 +945,7 @@ export default function ContactsPage() {
|
|||||||
<div
|
<div
|
||||||
data-tour="contacts-list"
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
@@ -991,7 +999,7 @@ export default function ContactsPage() {
|
|||||||
onClick={mobileBackToList}
|
onClick={mobileBackToList}
|
||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ export default function LocaleError({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button variant="outline" onClick={() => router.push('/')}>
|
<Button variant="outline" onClick={() => router.push('/')}>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 me-2" />
|
||||||
{t("go_home")}
|
{t("go_home")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={reset}>
|
<Button onClick={reset}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
{t("try_again")}
|
{t("try_again")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export default function FilesPage() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -611,7 +611,7 @@ export default function FilesPage() {
|
|||||||
: '0%' }}
|
: '0%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||||
{migrationProgress.current} / {migrationProgress.total}
|
{migrationProgress.current} / {migrationProgress.total}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
|
|||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
|
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
|
|||||||
<TourProvider>
|
<TourProvider>
|
||||||
<ProtocolLaunchHandlerProvider>
|
<ProtocolLaunchHandlerProvider>
|
||||||
<ProInterfaceRedirect />
|
<ProInterfaceRedirect />
|
||||||
|
<ImpersonationReconciler />
|
||||||
{children}
|
{children}
|
||||||
<PluginDialogHost />
|
<PluginDialogHost />
|
||||||
<PluginConsentDialog />
|
<PluginConsentDialog />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||||
@@ -133,8 +134,19 @@ export default function LoginPage() {
|
|||||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, 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 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.
|
||||||
|
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
|
||||||
|
const loginLogoStyle = hasLogoSize
|
||||||
|
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
@@ -718,7 +730,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{option.label}</span>
|
<span className="flex-1 text-start">{option.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -733,7 +745,7 @@ export default function LoginPage() {
|
|||||||
<div className="px-8 pt-12 pb-4 text-center">
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
<img
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(effLoginLogo)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-20 max-h-20 object-contain"
|
className="max-w-20 max-h-20 object-contain"
|
||||||
/>
|
/>
|
||||||
@@ -815,7 +827,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VersionBadge />
|
{loginShowVersion && <VersionBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -867,7 +879,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{option.label}</span>
|
<span className="flex-1 text-start">{option.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -881,19 +893,24 @@ export default function LoginPage() {
|
|||||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
{/* Header section with logo */}
|
{/* Header section with logo */}
|
||||||
<div className="px-8 pt-10 pb-6 text-center">
|
<div className="px-8 pt-10 pb-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||||
<img
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(effLoginLogo)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-16 max-h-16 object-contain"
|
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||||
|
style={loginLogoStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
{loginShowHeading && (
|
||||||
{isAddAccountMode ? t("add_account_title") : appName}
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
</h1>
|
||||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
)}
|
||||||
</p>
|
{loginShowSubtitle && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form section */}
|
{/* Form section */}
|
||||||
@@ -1121,7 +1138,7 @@ export default function LoginPage() {
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
placeholder={t("password_placeholder")}
|
placeholder={t("password_placeholder")}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -1142,8 +1159,12 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{!showTotpField ? (
|
||||||
|
loginShowTotp ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1155,6 +1176,7 @@ export default function LoginPage() {
|
|||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
{t("totp_toggle")}
|
{t("totp_toggle")}
|
||||||
</button>
|
</button>
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||||
@@ -1241,9 +1263,9 @@ export default function LoginPage() {
|
|||||||
disabled={oauthLoading || isLoading}
|
disabled={oauthLoading || isLoading}
|
||||||
>
|
>
|
||||||
{oauthLoading ? (
|
{oauthLoading ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
) : (
|
) : (
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
<LogIn className="w-4 h-4 me-2" />
|
||||||
)}
|
)}
|
||||||
{t("sign_in_sso")}
|
{t("sign_in_sso")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1349,7 +1371,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VersionBadge />
|
{loginShowVersion && <VersionBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+475
-129
File diff suppressed because it is too large
Load Diff
@@ -139,7 +139,7 @@ export default function ProHome() {
|
|||||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
const openTab = useProTabStore((s) => s.openTab);
|
const openTab = useProTabStore((s) => s.openTab);
|
||||||
const closeTab = useProTabStore((s) => s.closeTab);
|
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
|
||||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||||
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||||
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
@@ -354,7 +354,7 @@ export default function ProHome() {
|
|||||||
activeMainTabId={activeMainTabId}
|
activeMainTabId={activeMainTabId}
|
||||||
activeSplitTabId={activeSplitTabId}
|
activeSplitTabId={activeSplitTabId}
|
||||||
onActivate={setActiveTab}
|
onActivate={setActiveTab}
|
||||||
onClose={closeTab}
|
onClose={requestCloseTab}
|
||||||
onDragStateChange={setIsTabDragging}
|
onDragStateChange={setIsTabDragging}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useMessages } from 'next-intl';
|
import { useTranslations, useMessages } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -63,11 +62,12 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
|
|||||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
|
||||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||||
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
|
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
@@ -102,7 +102,6 @@ type Tab =
|
|||||||
| 'folders'
|
| 'folders'
|
||||||
| 'keywords'
|
| 'keywords'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'encryption'
|
|
||||||
| 'content_senders'
|
| 'content_senders'
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
| 'contacts'
|
| 'contacts'
|
||||||
@@ -116,8 +115,14 @@ type Tab =
|
|||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
|
|
||||||
|
// A plugin that exposes a `settings-section` slot gets its own first-class
|
||||||
|
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
|
||||||
|
// discoverable as a menu point rather than buried inside another panel.
|
||||||
|
type PluginTabId = `plugin:${string}`;
|
||||||
|
type SettingsTabId = Tab | PluginTabId;
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: Tab;
|
id: SettingsTabId;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
@@ -139,7 +144,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
security: Shield,
|
security: Shield,
|
||||||
encryption: KeyRound,
|
|
||||||
content_senders: EyeOff,
|
content_senders: EyeOff,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
contacts: BookUser,
|
contacts: BookUser,
|
||||||
@@ -217,7 +221,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
folders: ['settings.folders'],
|
folders: ['settings.folders'],
|
||||||
keywords: ['settings.keywords'],
|
keywords: ['settings.keywords'],
|
||||||
security: ['settings.security'],
|
security: ['settings.security'],
|
||||||
encryption: ['smime'],
|
|
||||||
content_senders: [
|
content_senders: [
|
||||||
'settings.email_behavior.always_light_mode',
|
'settings.email_behavior.always_light_mode',
|
||||||
'settings.email_behavior.external_content',
|
'settings.email_behavior.external_content',
|
||||||
@@ -252,7 +255,6 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
folders: 'mailbox subscribe',
|
folders: 'mailbox subscribe',
|
||||||
keywords: 'tags labels colors',
|
keywords: 'tags labels colors',
|
||||||
security: 'password 2fa two-factor passkey app password mfa',
|
security: 'password 2fa two-factor passkey app password mfa',
|
||||||
encryption: 's/mime smime certificate pgp gpg',
|
|
||||||
content_senders: 'block sender remote images privacy tracking',
|
content_senders: 'block sender remote images privacy tracking',
|
||||||
calendar: 'event schedule appointment meeting timezone',
|
calendar: 'event schedule appointment meeting timezone',
|
||||||
contacts: 'address book contact',
|
contacts: 'address book contact',
|
||||||
@@ -336,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
|||||||
advanced: 'about_data',
|
advanced: 'about_data',
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPersistedTab(): Tab {
|
function readPersistedTab(): SettingsTabId {
|
||||||
try {
|
try {
|
||||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||||
// Used only as the initial tab and intentionally NOT written to
|
// Used only as the initial tab and intentionally NOT written to
|
||||||
@@ -344,7 +346,7 @@ function readPersistedTab(): Tab {
|
|||||||
// default that the regular Settings button lands on. Cleared on mount.
|
// default that the regular Settings button lands on. Cleared on mount.
|
||||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||||
if (deepLink) {
|
if (deepLink) {
|
||||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
@@ -353,7 +355,7 @@ function readPersistedTab(): Tab {
|
|||||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
return saved as Tab;
|
return saved as SettingsTabId;
|
||||||
} catch {
|
} catch {
|
||||||
return 'appearance';
|
return 'appearance';
|
||||||
}
|
}
|
||||||
@@ -370,7 +372,15 @@ export default function SettingsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
|
||||||
|
// Active plugins that expose a `settings-section` slot — each becomes its own
|
||||||
|
// Settings menu entry. Referentially stable per registry mutation, so it is
|
||||||
|
// safe to feed useSyncExternalStore directly.
|
||||||
|
const pluginSettingsOffers = useSyncExternalStore(
|
||||||
|
pluginRegistrySubscribe,
|
||||||
|
() => pluginOffersForSlot('settings-section'),
|
||||||
|
() => pluginOffersForSlot('settings-section'),
|
||||||
|
);
|
||||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||||
// open, never the persisted default for future Settings-button clicks.
|
// open, never the persisted default for future Settings-button clicks.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -378,7 +388,7 @@ export default function SettingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
const messages = useMessages() as Record<string, unknown>;
|
const messages = useMessages() as Record<string, unknown>;
|
||||||
@@ -623,7 +633,6 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
|
||||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
@@ -631,6 +640,14 @@ export default function SettingsPage() {
|
|||||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
|
// Plugin-contributed settings pages: one entry per active plugin that
|
||||||
|
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
|
||||||
|
...pluginSettingsOffers.map((offer): TabDef => ({
|
||||||
|
id: `plugin:${offer.pluginId}` as PluginTabId,
|
||||||
|
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
|
||||||
|
icon: Puzzle,
|
||||||
|
group: 'apps',
|
||||||
|
})),
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
@@ -651,7 +668,7 @@ export default function SettingsPage() {
|
|||||||
].filter(Boolean) as Tab[])
|
].filter(Boolean) as Tab[])
|
||||||
: [];
|
: [];
|
||||||
const visibleTabs = managedAccountId
|
const visibleTabs = managedAccountId
|
||||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
|
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||||
: tabs;
|
: tabs;
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
@@ -667,12 +684,12 @@ export default function SettingsPage() {
|
|||||||
const matchesQuery = (tab: TabDef) => {
|
const matchesQuery = (tab: TabDef) => {
|
||||||
if (!trimmedQuery) return true;
|
if (!trimmedQuery) return true;
|
||||||
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
||||||
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
|
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const subResultsForTab = (tabId: Tab): SubResult[] => {
|
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
|
||||||
if (!trimmedQuery) return [];
|
if (!trimmedQuery) return [];
|
||||||
const list = tabSubResults[tabId] ?? [];
|
const list = tabSubResults[tabId as Tab] ?? [];
|
||||||
return list
|
return list
|
||||||
.filter((r) =>
|
.filter((r) =>
|
||||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||||
@@ -691,11 +708,11 @@ export default function SettingsPage() {
|
|||||||
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
// otherwise the usual 'appearance' default.
|
// otherwise the usual 'appearance' default.
|
||||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||||
const effectiveActiveTab: Tab = isActiveVisible
|
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||||
? activeTab
|
? activeTab
|
||||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
@@ -703,7 +720,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
|
||||||
handleTabSelect(tabId);
|
handleTabSelect(tabId);
|
||||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||||
};
|
};
|
||||||
@@ -719,11 +736,11 @@ export default function SettingsPage() {
|
|||||||
clearManagedAccount();
|
clearManagedAccount();
|
||||||
handleTabSelect('account');
|
handleTabSelect('account');
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
|
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||||
<span className="ml-auto text-sm font-medium truncate">
|
<span className="ms-auto text-sm font-medium truncate">
|
||||||
{t('scoped.managing', { name: managedAccount.name })}
|
{t('scoped.managing', { name: managedAccount.name })}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -743,7 +760,6 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{effectiveActiveTab === 'calendar' && (
|
{effectiveActiveTab === 'calendar' && (
|
||||||
managedAccountId
|
managedAccountId
|
||||||
@@ -762,6 +778,13 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
|
<PluginIframeSlot
|
||||||
|
key={effectiveActiveTab}
|
||||||
|
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||||
|
slot="settings-section"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -828,7 +851,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-9 pr-9 h-10"
|
className="ps-9 pe-9 h-10"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -876,7 +899,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
|
||||||
>
|
>
|
||||||
<span className="truncate">{sub.label}</span>
|
<span className="truncate">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -940,7 +963,7 @@ export default function SettingsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col",
|
"border-e border-border bg-secondary flex flex-col",
|
||||||
!isResizing && "transition-[width] duration-300"
|
!isResizing && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
@@ -953,7 +976,7 @@ export default function SettingsPage() {
|
|||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t('back_to_mail')}
|
{t('back_to_mail')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -968,7 +991,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-8 pr-8 h-9 text-sm"
|
className="ps-8 pe-8 h-9 text-sm"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -1005,7 +1028,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleTabSelect(tab.id)}
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
effectiveActiveTab === tab.id
|
effectiveActiveTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -1021,7 +1044,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<span className="truncate block">{sub.label}</span>
|
<span className="truncate block">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
|||||||
Per-server OAuth (optional, overrides global)
|
Per-server OAuth (optional, overrides global)
|
||||||
</button>
|
</button>
|
||||||
{d.oauthExpanded && (
|
{d.oauthExpanded && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ export function LogsTab() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/30">
|
<tr className="border-b border-border bg-muted/30">
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
|
|||||||
placeholder="Search extensions..."
|
placeholder="Search extensions..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||||
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
|
|||||||
{loading && !error && (
|
{loading && !error && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-sm font-medium text-foreground block mb-1">
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
value={formValues[key] ?? ''}
|
value={formValues[key] ?? ''}
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||||
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function PluginsTab() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
const [policyDirty, setPolicyDirty] = useState(false);
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
@@ -104,15 +108,17 @@ export function PluginsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
// Upload a bundle. The scanner may refuse it for containing patterns that are
|
||||||
const file = e.target.files?.[0];
|
// expected in a vendored crypto library (openpgp.js, pkijs); in that case the
|
||||||
if (!file) return;
|
// 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);
|
setUploading(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
if (override) formData.append('overrideWarnings', 'true');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
@@ -122,13 +128,22 @@ export function PluginsTab() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
setPendingScan(null);
|
||||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
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();
|
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 {
|
} else {
|
||||||
|
setPendingScan(null);
|
||||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
setPendingScan(null);
|
||||||
setMessage({ type: 'error', text: 'Upload failed' });
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
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) {
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
@@ -302,6 +324,51 @@ export function PluginsTab() {
|
|||||||
</div>
|
</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="border border-border rounded-lg">
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<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 { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
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 }>> = {
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
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' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
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: '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.' },
|
||||||
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: '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.' },
|
||||||
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: '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.' },
|
||||||
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.' },
|
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 = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function SettingsTab() {
|
|||||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
</p>
|
</p>
|
||||||
@@ -145,7 +145,7 @@ export function SettingsTab() {
|
|||||||
onRevert={() => handleRevert('jmapServers')}
|
onRevert={() => handleRevert('jmapServers')}
|
||||||
/>
|
/>
|
||||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
// Derive from the single source of truth so newly added built-in themes show
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
// up here automatically (was previously a hardcoded subset — see #496).
|
||||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeEntry {
|
interface ThemeEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
|
|||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={e => setCurrentPassword(e.target.value)}
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-12
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
href="/admin/change-password"
|
href="/admin/change-password"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
pathname === '/admin/change-password'
|
pathname === '/admin/change-password'
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
Sign out
|
Sign out
|
||||||
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail (desktop only) */}
|
{/* Slim webmail nav rail (desktop only) */}
|
||||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
) : (
|
) : (
|
||||||
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Sidebar (desktop only) */}
|
{/* Admin Sidebar (desktop only) */}
|
||||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Mobile drawer */}
|
{/* Mobile drawer */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
)}
|
)}
|
||||||
aria-label="Admin navigation"
|
aria-label="Admin navigation"
|
||||||
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowManifest(v => !v)}
|
onClick={() => setShowManifest(v => !v)}
|
||||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSource(v => !v)}
|
onClick={() => setShowSource(v => !v)}
|
||||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function GlobalError({
|
|||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+22
-2
@@ -1,9 +1,16 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { getLocaleDirection } from "@/i18n/direction";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
|
import { FaviconBadge } from "@/components/favicon-badge";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from "@/lib/admin/domain-branding";
|
||||||
import { withBasePath } from "@/lib/browser-navigation";
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
@@ -38,7 +45,19 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
// The <head> favicon must honor per-domain branding, exactly like
|
||||||
|
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
||||||
|
// request host and prefer its override; fall back to the global
|
||||||
|
// admin/env/default value when the host has no favicon override (#585).
|
||||||
|
const host = pickRequestHost(await headers());
|
||||||
|
const domainOverride = matchDomainBranding(
|
||||||
|
host,
|
||||||
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||||
|
).faviconUrl;
|
||||||
|
const faviconUrl =
|
||||||
|
domainOverride && domainOverride.length > 0
|
||||||
|
? domainOverride
|
||||||
|
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||||
// Localize the <head> description to match the UI language; a hardcoded
|
// Localize the <head> description to match the UI language; a hardcoded
|
||||||
// English description is another signal that makes Chrome offer to
|
// English description is another signal that makes Chrome offer to
|
||||||
// "translate this page". Resolve the locale from the request path, since this
|
// "translate this page". Resolve the locale from the request path, since this
|
||||||
@@ -76,7 +95,7 @@ export default async function RootLayout({
|
|||||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
@@ -114,6 +133,7 @@ export default async function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
|
<FaviconBadge />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
|
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={confirmedNonJmap}
|
checked={confirmedNonJmap}
|
||||||
@@ -870,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRowErrors && (
|
{hasRowErrors && (
|
||||||
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
|
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
|
||||||
{rowErrors.map((err, i) => (
|
{rowErrors.map((err, i) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
@@ -1384,7 +1384,7 @@ function BrandingAsset({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showUrlField && (
|
{showUrlField && (
|
||||||
<div className="mt-3 pl-[4.75rem]">
|
<div className="mt-3 ps-[4.75rem]">
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -1394,7 +1394,7 @@ function BrandingAsset({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||||
{value || <span className="text-muted-foreground italic">-</span>}
|
{value || <span className="text-muted-foreground italic">-</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||||
|
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||||
|
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||||
|
// approval) is enforced host-side before this route is ever framed; the page
|
||||||
|
// itself carries no extra privilege.
|
||||||
|
//
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PrivilegedPluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/account/stalwart/jmap
|
* POST /api/account/stalwart/jmap
|
||||||
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
|
|
||||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
const directUrl = `${creds.serverUrl}/jmap/`;
|
||||||
method: 'POST',
|
let response = await postJmap(directUrl, creds.authHeader, body);
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
if (response.status === 404) {
|
||||||
'Content-Type': 'application/json',
|
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
|
||||||
},
|
// (path prefix, non-Stalwart URL layout). Resolve the session's
|
||||||
body,
|
// 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();
|
const responseText = await response.text();
|
||||||
return new NextResponse(responseText, {
|
return new NextResponse(responseText, {
|
||||||
@@ -38,9 +51,27 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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', {
|
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 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: (manifest.type as string) || 'hook',
|
type: (manifest.type as string) || 'hook',
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions,
|
permissions,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
enabled: existingPlugin?.enabled ?? true,
|
enabled: existingPlugin?.enabled ?? true,
|
||||||
|
|||||||
@@ -158,15 +158,47 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const code = await entryFile.async('string');
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
// Security: block plugins containing dangerous JS patterns
|
// Security: scan for dangerous JS patterns across EVERY script in the
|
||||||
const warnings: string[] = [];
|
// bundle, not just the entrypoint - a second .js file was previously never
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
// looked at.
|
||||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
//
|
||||||
pattern.lastIndex = 0;
|
// 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(
|
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 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,6 +215,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: manifest.author as string,
|
author: manifest.author as string,
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: manifest.type as string,
|
type: manifest.type as string,
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -211,8 +244,20 @@ export async function POST(request: NextRequest) {
|
|||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
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) {
|
} catch (error) {
|
||||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
|
|||||||
* Master-user impersonation via signed JWT. The token carries the target
|
* Master-user impersonation via signed JWT. The token carries the target
|
||||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||||
* master credentials from env, then mints the same session cookies the
|
* 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.
|
* 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
|
* 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.
|
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 303,
|
status: 303,
|
||||||
headers: { Location: '/' },
|
headers: { Location: '/?impersonated=1' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(cookieName);
|
// Drop the refresh token only when the server definitively rejected it
|
||||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
// the cookie lets the session resume once the server is back.
|
||||||
|
const status = tokenResponse.status;
|
||||||
|
if (status === 400 || status === 401 || status === 403) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
|
||||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
|
||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
|||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||||
*
|
*
|
||||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||||
|
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||||
|
* verbatim against the password hash and fails. The MFA token must instead be
|
||||||
|
* supplied as a distinct field through the structured login endpoint.
|
||||||
*
|
*
|
||||||
* Tries three strategies:
|
* This route drives that flow server-side (avoiding browser CORS against the
|
||||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
* mail server, same as OAuth discovery):
|
||||||
* 2. ROPC grant without client_id
|
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
* receiving a short-lived authorization `clientCode`.
|
||||||
|
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||||
|
* the code (with PKCE) for access/refresh tokens.
|
||||||
|
*
|
||||||
|
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||||
|
* the (≈30s) code in every request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function tryTokenRequest(
|
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||||
tokenEndpoint: string,
|
// any client id unless `require_client_registration` is enabled (default off);
|
||||||
params: URLSearchParams,
|
// when it is enabled the admin must configure `oauthClientId` with this
|
||||||
extraHeaders?: Record<string, string>,
|
// redirect URI registered.
|
||||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
|
||||||
const response = await fetch(tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
interface LoginResult {
|
||||||
const errorText = await response.text();
|
type?: string;
|
||||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||||
}
|
// camelCased server-side, not the struct fields (the request fields are).
|
||||||
|
client_code?: string;
|
||||||
const tokens = await response.json();
|
|
||||||
if (!tokens.access_token) {
|
|
||||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, tokens };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
function trimUrl(url: string): string {
|
||||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
return url.replace(/\/+$/, '');
|
||||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
}
|
||||||
// go through the SSRF validator regardless of the setting.
|
|
||||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
|
||||||
// 1. Try OAuth discovery
|
|
||||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
|
||||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
|
||||||
|
|
||||||
// 2. Try common Stalwart token endpoint paths directly
|
async function attemptLogin(
|
||||||
const candidates = [
|
upstreamUrl: string,
|
||||||
`${serverUrl}/auth/token`,
|
username: string,
|
||||||
`${serverUrl}/api/oauth/token`,
|
password: string,
|
||||||
];
|
totp: string | undefined,
|
||||||
|
redirectUri: string,
|
||||||
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const base = trimUrl(upstreamUrl);
|
||||||
|
|
||||||
for (const url of candidates) {
|
// Per-server OAuth credentials override the global ones when the requested
|
||||||
try {
|
// server entry has its own oauth block configured.
|
||||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
const entry = findServerById(serverList, serverId);
|
||||||
if (probe.status !== 404 && probe.status !== 405) {
|
const clientId = entry?.oauth?.clientId
|
||||||
return url;
|
|| configManager.get<string>('oauthClientId', '')
|
||||||
}
|
|| process.env.OAUTH_CLIENT_ID
|
||||||
} catch {
|
|| DEFAULT_CLIENT_ID;
|
||||||
// Network error - endpoint not reachable
|
const clientSecret = entry?.oauth?.clientSecret
|
||||||
|
|| configManager.get<string>('oauthClientSecret', '')
|
||||||
|
|| process.env.OAUTH_CLIENT_SECRET
|
||||||
|
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
// PKCE proves the token exchange originates from the same client that
|
||||||
|
// initiated the login, so no client secret is required for public clients.
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
const challenge = await generateCodeChallenge(verifier);
|
||||||
|
|
||||||
|
// Step 1: structured login with a separate MFA token.
|
||||||
|
let login: LoginResult;
|
||||||
|
try {
|
||||||
|
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'authCode',
|
||||||
|
accountName: username,
|
||||||
|
accountSecret: password,
|
||||||
|
...(totp ? { mfaToken: totp } : {}),
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge: challenge,
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
const detail = (await loginResponse.text()).substring(0, 500);
|
||||||
|
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||||
|
// A 404 means the server predates the structured login endpoint; let the
|
||||||
|
// caller fall back to the legacy basic-auth path.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||||
|
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login = await loginResponse.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
switch (login.type) {
|
||||||
|
case 'authenticated':
|
||||||
|
break;
|
||||||
|
case 'mfaRequired':
|
||||||
|
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||||
|
case 'failure':
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!login.client_code) {
|
||||||
|
logger.warn('TOTP login: authenticated response missing client_code');
|
||||||
|
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: exchange the authorization code for tokens.
|
||||||
|
const tokenParams = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: login.client_code,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
});
|
||||||
|
// Confidential clients still send their secret; harmless for public clients.
|
||||||
|
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||||
|
|
||||||
|
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: tokenParams.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||||
|
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = await tokenResponse.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('TOTP login succeeded');
|
||||||
|
void recordLogin(username, base);
|
||||||
|
return await storeAndRespond(
|
||||||
|
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||||
|
slot,
|
||||||
|
serverId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||||
|
|
||||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||||
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let resolvedServerId: string | null = null;
|
let resolvedServerId: string | null = null;
|
||||||
let adminTrusted = false;
|
|
||||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||||
|
|
||||||
if (matchedEntry) {
|
if (matchedEntry) {
|
||||||
upstreamUrl = matchedEntry.url;
|
upstreamUrl = matchedEntry.url;
|
||||||
resolvedServerId = matchedEntry.id;
|
resolvedServerId = matchedEntry.id;
|
||||||
adminTrusted = true;
|
|
||||||
} else if (configuredServerUrl) {
|
} else if (configuredServerUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = configuredServerUrl;
|
||||||
adminTrusted = true;
|
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
logger.warn('TOTP login: rejected non-public server URL');
|
||||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||||
}
|
}
|
||||||
upstreamUrl = serverUrl;
|
upstreamUrl = serverUrl;
|
||||||
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
// The redirect URI must be identical in the login and token-exchange steps,
|
||||||
if (!tokenEndpoint) {
|
// and (when require_client_registration is on) registered for the client.
|
||||||
logger.warn('TOTP token exchange: no token endpoint found');
|
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
// fall back to the upstream URL so the two steps still agree.
|
||||||
}
|
const redirectUri =
|
||||||
|
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||||
|
? bodyRedirectUri
|
||||||
|
: trimUrl(upstreamUrl);
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attemptAllStrategies(
|
|
||||||
tokenEndpoint: string,
|
|
||||||
serverUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
slot: number,
|
|
||||||
serverId: string | null,
|
|
||||||
): Promise<NextResponse> {
|
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
|
||||||
|
|
||||||
// Per-server OAuth credentials override the global ones when the requested
|
|
||||||
// server entry has its own oauth block configured.
|
|
||||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
|
||||||
const entry = findServerById(serverList, serverId);
|
|
||||||
const clientId = entry?.oauth?.clientId
|
|
||||||
|| configManager.get<string>('oauthClientId', '')
|
|
||||||
|| process.env.OAUTH_CLIENT_ID;
|
|
||||||
const clientSecret = entry?.oauth?.clientSecret
|
|
||||||
|| configManager.get<string>('oauthClientSecret', '')
|
|
||||||
|| process.env.OAUTH_CLIENT_SECRET
|
|
||||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
|
||||||
|
|
||||||
// Strategy 1: ROPC with client_id (if configured)
|
|
||||||
if (clientId) {
|
|
||||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
|
||||||
if (clientSecret) params.set('client_secret', clientSecret);
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: ROPC without client_id
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams({ grant_type: 'password' });
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
|
||||||
return NextResponse.json({
|
|
||||||
error: 'token_exchange_failed',
|
|
||||||
detail: 'All token exchange strategies failed',
|
|
||||||
attempts,
|
|
||||||
}, { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeAndRespond(
|
async function storeAndRespond(
|
||||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
slot: number,
|
slot: number,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
@@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [
|
|||||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface JmapSession {
|
|
||||||
apiUrl?: string;
|
|
||||||
primaryAccounts?: Record<string, string>;
|
|
||||||
capabilities?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgendaEvent {
|
interface AgendaEvent {
|
||||||
id: string;
|
id: string;
|
||||||
uid: string | null;
|
uid: string | null;
|
||||||
@@ -141,9 +136,9 @@ export async function POST(request: NextRequest) {
|
|||||||
using.push('urn:ietf:params:jmap:principals:owner');
|
using.push('urn:ietf:params:jmap:principals:owner');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
// Send method calls to the session's apiUrl rebased onto serverUrl's host
|
||||||
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
// — never to session.apiUrl's (possibly unreachable) public host.
|
||||||
const apiUrl = `${creds.serverUrl}/jmap/`;
|
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
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)));
|
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(
|
async function jmapPost(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
authHeader: string,
|
authHeader: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const res = await fetch(apiUrl, {
|
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`JMAP request failed (${res.status})`);
|
throw new Error(`JMAP request failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export async function GET(request: NextRequest) {
|
|||||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||||
|
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
|
||||||
|
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),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||||
|
|||||||
+563
-309
File diff suppressed because one or more lines are too long
@@ -37,6 +37,9 @@ export async function GET() {
|
|||||||
author: p.author,
|
author: p.author,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
// Requested execution tier; clients gate the same-origin privileged
|
||||||
|
// sandbox on this (plus signature + approval + consent).
|
||||||
|
tier: p.tier,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
// Policy is the canonical source for force-enable. The per-plugin field
|
// Policy is the canonical source for force-enable. The per-plugin field
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ export async function GET(
|
|||||||
domainOverrides.pwaIconUrl ||
|
domainOverrides.pwaIconUrl ||
|
||||||
domainOverrides.faviconUrl ||
|
domainOverrides.faviconUrl ||
|
||||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
|
||||||
if (!iconUrl) {
|
// Fall back to the built-in default so this endpoint ALWAYS returns an app
|
||||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
// icon (custom if configured, else the bundled default). This lets callers
|
||||||
}
|
// that can't run the custom-vs-default check themselves - notably the
|
||||||
|
// service worker's notifications - use a single stable URL.
|
||||||
|
`/icon-${size}x${size}.png`;
|
||||||
|
|
||||||
const pngHeaders = {
|
const pngHeaders = {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+57
-5
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@@ -558,6 +559,40 @@ body {
|
|||||||
overscroll-behavior: none;
|
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) */
|
/* Slide in from right animation (for mobile views) */
|
||||||
@keyframes slide-in-from-right {
|
@keyframes slide-in-from-right {
|
||||||
from {
|
from {
|
||||||
@@ -660,13 +695,13 @@ body {
|
|||||||
|
|
||||||
.tiptap ul {
|
.tiptap ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
padding-left: 1.5rem;
|
padding-inline-start: 1.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap ol {
|
.tiptap ol {
|
||||||
list-style-type: decimal;
|
list-style-type: decimal;
|
||||||
padding-left: 1.5rem;
|
padding-inline-start: 1.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,8 +710,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap blockquote {
|
.tiptap blockquote {
|
||||||
border-left: 3px solid var(--color-border);
|
border-inline-start: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-inline-start: 1rem;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
}
|
}
|
||||||
@@ -719,7 +754,7 @@ body {
|
|||||||
|
|
||||||
.tiptap p.is-editor-empty:first-child::before {
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: inline-start;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -808,3 +843,20 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
|
||||||
|
and panel-collapse affordances point the correct way in right-to-left layouts.
|
||||||
|
lucide-react emits a `lucide-<name>` class per icon, so we target the
|
||||||
|
directional ones only — vertical chevrons (up/down) are intentionally left. */
|
||||||
|
[dir="rtl"] .lucide-chevron-left,
|
||||||
|
[dir="rtl"] .lucide-chevron-right,
|
||||||
|
[dir="rtl"] .lucide-chevrons-left,
|
||||||
|
[dir="rtl"] .lucide-chevrons-right,
|
||||||
|
[dir="rtl"] .lucide-arrow-left,
|
||||||
|
[dir="rtl"] .lucide-arrow-right,
|
||||||
|
[dir="rtl"] .lucide-arrow-big-left,
|
||||||
|
[dir="rtl"] .lucide-arrow-big-right,
|
||||||
|
[dir="rtl"] .lucide-panel-left,
|
||||||
|
[dir="rtl"] .lucide-panel-right {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
|
|||||||
)}>
|
)}>
|
||||||
{formatDateHeader(group.date)}
|
{formatDateHeader(group.date)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
<span className="text-xs text-muted-foreground ms-2">
|
||||||
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +148,9 @@ export function CalendarAgendaView({
|
|||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = getEventStartDate(ev);
|
const start = getEventStartDate(ev);
|
||||||
const end = getEventEndDate(ev);
|
const end = getEventEndDate(ev);
|
||||||
|
// iTIP CANCEL marks the attendee's copy with status "cancelled"
|
||||||
|
// instead of deleting it (#572).
|
||||||
|
const isCancelled = ev.status === "cancelled";
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
@@ -159,7 +162,10 @@ export function CalendarAgendaView({
|
|||||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onHoverLeave?.()}
|
onMouseLeave={() => onHoverLeave?.()}
|
||||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
className={cn(
|
||||||
|
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
|
||||||
|
isCancelled && "opacity-60"
|
||||||
|
)}
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||||
@@ -181,7 +187,7 @@ export function CalendarAgendaView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
|
||||||
{ev.title || t("events.no_title")}
|
{ev.title || t("events.no_title")}
|
||||||
</div>
|
</div>
|
||||||
{locationName && (
|
{locationName && (
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function CalendarDayView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="relative text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-end pe-2"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{h > 0 && (
|
{h > 0 && (
|
||||||
@@ -231,7 +231,7 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 relative border-l border-border"
|
className="flex-1 relative border-s border-border"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={t("views.day")}
|
aria-label={t("views.day")}
|
||||||
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
||||||
@@ -312,7 +312,7 @@ export function CalendarDayView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
|
||||||
<div className="flex-1 h-px bg-destructive" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +346,7 @@ export function CalendarDayView({
|
|||||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
|
||||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import { format, parseISO } from "date-fns";
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
||||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
|
|
||||||
} from "date-fns";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -47,16 +46,28 @@ export function CalendarMonthView({
|
|||||||
pendingPreview,
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
// 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,
|
||||||
|
getMonthGridDays,
|
||||||
|
checkIsToday,
|
||||||
|
checkIsSameMonth,
|
||||||
|
checkIsSameDay,
|
||||||
|
formatDayNumber,
|
||||||
|
formatFullDate,
|
||||||
|
} = useCalendarLocale();
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(
|
||||||
const monthStart = startOfMonth(selectedDate);
|
() => getMonthGridDays(selectedDate),
|
||||||
const monthEnd = endOfMonth(selectedDate);
|
[selectedDate, getMonthGridDays],
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
);
|
||||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
|
||||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
||||||
}, [selectedDate, weekStart]);
|
|
||||||
|
|
||||||
const calendarMap = useMemo(() => {
|
const calendarMap = useMemo(() => {
|
||||||
const map = new Map<string, Calendar>();
|
const map = new Map<string, Calendar>();
|
||||||
@@ -83,10 +94,6 @@ export function CalendarMonthView({
|
|||||||
return map;
|
return map;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const dayHeaders = firstDayOfWeek === 0
|
|
||||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
|
||||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
|
||||||
|
|
||||||
const weeks = useMemo(() => {
|
const weeks = useMemo(() => {
|
||||||
const result: Date[][] = [];
|
const result: Date[][] = [];
|
||||||
for (let i = 0; i < days.length; i += 7) {
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
@@ -141,11 +148,11 @@ export function CalendarMonthView({
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
|
||||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||||
{dayHeaders.map((d) => (
|
{dayHeaderKeys.map((d) => (
|
||||||
<div key={d} role="columnheader" className={cn(
|
<div key={d} role="columnheader" className={cn(
|
||||||
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
|
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||||
isMobile && "py-1.5 text-[11px]"
|
isMobile && "py-1.5 text-[11px]"
|
||||||
)}>
|
)}>
|
||||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||||
@@ -158,15 +165,15 @@ export function CalendarMonthView({
|
|||||||
<div key={wi} className={cn(
|
<div key={wi} className={cn(
|
||||||
"relative flex-1 border-b border-border last:border-b-0",
|
"relative flex-1 border-b border-border last:border-b-0",
|
||||||
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
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">
|
<div className="grid grid-cols-7 h-full">
|
||||||
{week.map((day) => {
|
{week.map((day) => {
|
||||||
const inMonth = isSameMonth(day, selectedDate);
|
const inMonth = checkIsSameMonth(day, selectedDate);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = checkIsSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = checkIsToday(day);
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = eventsByDate.get(key) || [];
|
const dayEvents = eventsByDate.get(key) || [];
|
||||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
const fullDateLabel = formatFullDate(day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -181,7 +188,7 @@ export function CalendarMonthView({
|
|||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
||||||
!inMonth && "bg-muted/30",
|
!inMonth && "bg-muted/30",
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
selected && isMobile && "bg-primary/10",
|
selected && isMobile && "bg-primary/10",
|
||||||
@@ -199,10 +206,10 @@ export function CalendarMonthView({
|
|||||||
inMonth && !selected && !today && "font-medium"
|
inMonth && !selected && !today && "font-medium"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{formatDayNumber(day)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile && !showChips ? (
|
||||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||||
{dayEvents.slice(0, 3).map((ev) => {
|
{dayEvents.slice(0, 3).map((ev) => {
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
@@ -219,7 +226,7 @@ export function CalendarMonthView({
|
|||||||
{dayEvents.length > 3 && (
|
{dayEvents.length > 3 && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||||
)}
|
)}
|
||||||
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
@@ -232,25 +239,28 @@ export function CalendarMonthView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMobile && pendingPreview && (() => {
|
{showChips && pendingPreview && (() => {
|
||||||
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
||||||
if (previewDayIdx === -1) return null;
|
if (previewDayIdx === -1) return null;
|
||||||
const previewRow = rowCount;
|
const previewRow = rowCount;
|
||||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
const color = cal?.color || "#3b82f6";
|
const color = cal?.color || "#3b82f6";
|
||||||
return (
|
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
|
<div
|
||||||
className="absolute px-0.5"
|
className="absolute px-0.5"
|
||||||
style={{
|
style={{
|
||||||
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||||
top: previewRow * 22,
|
top: previewRow * rowHeight,
|
||||||
height: 20,
|
height: chipHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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` }}
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||||
>
|
>
|
||||||
{pendingPreview.title}
|
{pendingPreview.title}
|
||||||
@@ -260,8 +270,8 @@ export function CalendarMonthView({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{!isMobile && segments.length > 0 && (
|
{showChips && segments.length > 0 && (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
const calId = getPrimaryCalendarId(segment.event);
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
@@ -271,8 +281,8 @@ export function CalendarMonthView({
|
|||||||
style={{
|
style={{
|
||||||
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||||
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
||||||
top: segment.row * 22,
|
top: segment.row * rowHeight,
|
||||||
height: 20,
|
height: chipHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
@@ -286,6 +296,7 @@ export function CalendarMonthView({
|
|||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
onContextMenu={onContextMenuEvent}
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
|
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export function CalendarSidebarPanel({
|
|||||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||||
<span>{t('tasks.label')}</span>
|
<span>{t('tasks.label')}</span>
|
||||||
{pendingTaskCount > 0 && (
|
{pendingTaskCount > 0 && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
<span className="ms-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||||
)}
|
)}
|
||||||
{overdueTaskCount > 0 && (
|
{overdueTaskCount > 0 && (
|
||||||
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||||
@@ -437,7 +437,7 @@ export function CalendarSidebarPanel({
|
|||||||
onCreateCalendar();
|
onCreateCalendar();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||||
title={tMgmt('add_calendar')}
|
title={tMgmt('add_calendar')}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
@@ -445,7 +445,7 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="mt-1 pl-3">
|
<div className="mt-1 ps-3">
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
||||||
import { addDays, startOfWeek } from "date-fns";
|
import { startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
interface CalendarToolbarProps {
|
interface CalendarToolbarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -50,7 +51,14 @@ export function CalendarToolbar({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
const {
|
||||||
|
weekStartsOn,
|
||||||
|
formatMonthYear,
|
||||||
|
formatMonthYearShort,
|
||||||
|
formatWeekRange,
|
||||||
|
formatWeekRangeShort,
|
||||||
|
formatFullDate,
|
||||||
|
} = useCalendarLocale();
|
||||||
const views: CalendarViewMode[] = enableCalendarTasks
|
const views: CalendarViewMode[] = enableCalendarTasks
|
||||||
? ["month", "week", "day", "agenda", "tasks"]
|
? ["month", "week", "day", "agenda", "tasks"]
|
||||||
: ["month", "week", "day", "agenda"];
|
: ["month", "week", "day", "agenda"];
|
||||||
@@ -72,28 +80,22 @@ export function CalendarToolbar({
|
|||||||
switch (viewMode) {
|
switch (viewMode) {
|
||||||
case "month":
|
case "month":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatMonthYearShort(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatMonthYear(selectedDate);
|
||||||
case "week": {
|
case "week": {
|
||||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
const ws = startOfWeek(selectedDate, { weekStartsOn });
|
||||||
const we = addDays(ws, 6);
|
return isMobile
|
||||||
if (isMobile) {
|
? formatWeekRangeShort(ws)
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`;
|
: formatWeekRange(ws);
|
||||||
}
|
|
||||||
const sameMonth = ws.getMonth() === we.getMonth();
|
|
||||||
if (sameMonth) {
|
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
|
|
||||||
}
|
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
|
||||||
}
|
}
|
||||||
case "day":
|
case "day":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
? formatFullDate(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
: formatFullDate(selectedDate);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatMonthYearShort(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatMonthYear(selectedDate);
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return t("views.tasks");
|
return t("views.tasks");
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ export function CalendarToolbar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="h-8 w-8 -ml-1 mr-1"
|
className="h-8 w-8 -ms-1 me-1"
|
||||||
aria-label={t("nav_open_menu")}
|
aria-label={t("nav_open_menu")}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
@@ -138,7 +140,7 @@ export function CalendarToolbar({
|
|||||||
{onMenuClick && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||||
aria-label={t("nav_open_menu")}
|
aria-label={t("nav_open_menu")}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
@@ -147,7 +149,7 @@ export function CalendarToolbar({
|
|||||||
{onNavigateBack && (
|
{onNavigateBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateBack}
|
onClick={onNavigateBack}
|
||||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||||
aria-label={t("back_to_month")}
|
aria-label={t("back_to_month")}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -162,7 +164,7 @@ export function CalendarToolbar({
|
|||||||
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
|
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
|
||||||
{t("views.today")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +201,7 @@ export function CalendarToolbar({
|
|||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{showCalendarDropdown && (
|
{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">
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
{t("my_calendars")}
|
{t("my_calendars")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -284,7 +286,7 @@ export function CalendarToolbar({
|
|||||||
{/* ── DESKTOP TOOLBAR ── */}
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
|
||||||
{t("views.today")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
@@ -293,7 +295,7 @@ export function CalendarToolbar({
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-base font-semibold ml-2 select-none">
|
<span className="text-base font-semibold ms-2 select-none">
|
||||||
{getDateLabel()}
|
{getDateLabel()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,12 +330,12 @@ export function CalendarToolbar({
|
|||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Upload className="w-4 h-4 me-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ms-1" />
|
||||||
</Button>
|
</Button>
|
||||||
{showImportDropdown && (
|
{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 && (
|
{onImport && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
||||||
@@ -359,7 +361,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function CalendarWeekView({
|
|||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
|
||||||
|
|
||||||
const weekDays = useMemo(() => {
|
const weekDays = useMemo(() => {
|
||||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||||
@@ -211,7 +211,7 @@ export function CalendarWeekView({
|
|||||||
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-end", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
@@ -306,7 +306,7 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
<div className="flex border-b border-border" role="row">
|
<div className="flex border-b border-border" role="row">
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
|
||||||
<div className="flex-1 border-l border-border grid grid-cols-7">
|
<div className="flex-1 border-s border-border grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
@@ -318,7 +318,7 @@ export function CalendarWeekView({
|
|||||||
role="columnheader"
|
role="columnheader"
|
||||||
aria-label={fullLabel}
|
aria-label={fullLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
|
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
todayCol && "font-bold",
|
todayCol && "font-bold",
|
||||||
)}
|
)}
|
||||||
@@ -345,7 +345,7 @@ export function CalendarWeekView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="relative text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-end pe-2"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{h > 0 && (
|
{h > 0 && (
|
||||||
@@ -357,7 +357,7 @@ export function CalendarWeekView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 border-l border-border relative grid grid-cols-7">
|
<div className="flex-1 border-s border-border relative grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
@@ -367,7 +367,7 @@ export function CalendarWeekView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="relative border-r border-border last:border-r-0"
|
className="relative border-e border-border last:border-e-0"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
||||||
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||||
@@ -448,7 +448,7 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
|
||||||
<div className="flex-1 h-px bg-destructive" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +482,7 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
|
||||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
|
|||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
{tCommon("loading")}
|
{tCommon("loading")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
try { return format(d, fmt); } catch { return "--:--"; }
|
try { return format(d, fmt); } catch { return "--:--"; }
|
||||||
};
|
};
|
||||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
|
||||||
|
// deleting it (#572) - render it struck through and dimmed.
|
||||||
|
const isCancelled = event.status === "cancelled";
|
||||||
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent) => {
|
const handleDragStart = useCallback((e: DragEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -131,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
|
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
|
||||||
"min-h-[44px] sm:min-h-0",
|
"min-h-[44px] sm:min-h-0",
|
||||||
"hover:opacity-80 transition-opacity",
|
"hover:opacity-80 transition-opacity",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}20`, color, ...style }}
|
style={{ backgroundColor: `${color}20`, color, ...style }}
|
||||||
@@ -144,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{event.title || t("events.no_title")}</span>
|
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
continuesAfter && "rounded-r-sm",
|
continuesAfter && "rounded-r-sm",
|
||||||
continuesAfter && "pr-2",
|
continuesAfter && "pe-2",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
@@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{showTimeInMonthView && !event.showWithoutTime && (
|
{showTimeInMonthView && !event.showWithoutTime && (
|
||||||
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<div className="opacity-80 text-[10px]">
|
<div className="opacity-80 text-[10px]">
|
||||||
{timeString}
|
{timeString}
|
||||||
|
|||||||
@@ -290,20 +290,23 @@ export function EventDetailPopover({
|
|||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-base font-semibold truncate text-foreground">
|
<h3 className={cn(
|
||||||
|
"text-base font-semibold truncate text-foreground",
|
||||||
|
event.status === "cancelled" && "line-through text-muted-foreground"
|
||||||
|
)}>
|
||||||
{event.title || t("events.no_title")}
|
{event.title || t("events.no_title")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{calendar && (
|
{calendar && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
|
||||||
{calendar.name}
|
{calendar.name}
|
||||||
{event.status === "tentative" && (
|
{event.status === "tentative" && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||||
{t("detail.tentative")}
|
{t("detail.tentative")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{event.status === "cancelled" && (
|
{event.status === "cancelled" && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||||
{t("detail.cancelled")}
|
{t("detail.cancelled")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -343,13 +346,13 @@ export function EventDetailPopover({
|
|||||||
<>
|
<>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(startDate)}
|
{formatEventDate(startDate)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{formatTime(startDate)}
|
{formatTime(startDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(endDate)}
|
{formatEventDate(endDate)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{formatTime(endDate)}
|
{formatTime(endDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,11 +367,11 @@ export function EventDetailPopover({
|
|||||||
{formatEventDate(startDate)}
|
{formatEventDate(startDate)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{event.showWithoutTime ? (
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{formatTime(startDate)} – {formatTime(endDate)}
|
{formatTime(startDate)} – {formatTime(endDate)}
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -434,7 +437,7 @@ export function EventDetailPopover({
|
|||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
{p.name || p.email}
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ms-1">
|
||||||
({t("participants.organizer").toLowerCase()})
|
({t("participants.organizer").toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -512,7 +515,7 @@ export function EventDetailPopover({
|
|||||||
disabled={!noteText.trim() || isSavingNote}
|
disabled={!noteText.trim() || isSavingNote}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="w-3 h-3 mr-1" />
|
<Send className="w-3 h-3 me-1" />
|
||||||
{t("detail.save_note")}
|
{t("detail.save_note")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,7 +549,7 @@ export function EventDetailPopover({
|
|||||||
: "text-success border-success/30 hover:bg-success/10"
|
: "text-success border-success/30 hover:bg-success/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -559,7 +562,7 @@ export function EventDetailPopover({
|
|||||||
: "border border-warning/30 text-warning hover:bg-warning/10"
|
: "border border-warning/30 text-warning hover:bg-warning/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -572,7 +575,7 @@ export function EventDetailPopover({
|
|||||||
: "text-destructive hover:bg-destructive/10"
|
: "text-destructive hover:bg-destructive/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -606,7 +609,7 @@ export function EventDetailPopover({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
||||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
<Pencil className="w-3.5 h-3.5 me-1" />
|
||||||
{t("events.edit")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -616,7 +619,7 @@ export function EventDetailPopover({
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
title={t("events.duplicate")}
|
title={t("events.duplicate")}
|
||||||
>
|
>
|
||||||
<Copy className="w-3.5 h-3.5 mr-1" />
|
<Copy className="w-3.5 h-3.5 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -522,10 +522,16 @@ export function EventModal({
|
|||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
effectiveAttendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||||
|
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||||
|
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||||
|
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
|
||||||
|
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
|
||||||
data.replyTo = null;
|
data.replyTo = null;
|
||||||
|
data.organizerCalendarAddress = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
@@ -660,11 +666,11 @@ export function EventModal({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -673,7 +679,7 @@ export function EventModal({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ms-2">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -695,7 +701,7 @@ export function EventModal({
|
|||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
{t("participants.title")}
|
{t("participants.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pl-5">
|
<div className="space-y-1 ps-5">
|
||||||
{participants.map(p => (
|
{participants.map(p => (
|
||||||
<div key={p.id} className="flex items-center justify-between text-sm">
|
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||||
<span className="truncate">{p.name || p.email}</span>
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
@@ -720,7 +726,7 @@ export function EventModal({
|
|||||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-success border-success/30 hover:bg-success/10"}
|
: "text-success border-success/30 hover:bg-success/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -731,7 +737,7 @@ export function EventModal({
|
|||||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -742,7 +748,7 @@ export function EventModal({
|
|||||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-destructive hover:bg-destructive/10"}
|
: "text-destructive hover:bg-destructive/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -778,7 +784,7 @@ export function EventModal({
|
|||||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
{eventCalendar && (
|
{eventCalendar && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
@@ -810,13 +816,13 @@ export function EventModal({
|
|||||||
<>
|
<>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(startD)}
|
{formatEventDate(startD)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{format(startD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(endD)}
|
{formatEventDate(endD)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{format(endD, timeDisplayFmt)}
|
{format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,11 +838,11 @@ export function EventModal({
|
|||||||
{formatEventDate(startD)}
|
{formatEventDate(startD)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{event.showWithoutTime ? (
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
<span className="ms-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -883,7 +889,7 @@ export function EventModal({
|
|||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
{p.name || p.email}
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
|
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||||
@@ -936,21 +942,21 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 me-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{onDuplicate && !showDeleteConfirm && (
|
{onDuplicate && !showDeleteConfirm && (
|
||||||
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!showDeleteConfirm && (
|
{!showDeleteConfirm && (
|
||||||
<Button onClick={() => setMode("edit")}>
|
<Button onClick={() => setMode("edit")}>
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("events.edit")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1314,7 +1320,7 @@ export function EventModal({
|
|||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="text-red-600 dark:text-red-400"
|
className="text-red-600 dark:text-red-400"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 me-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -1326,7 +1332,7 @@ export function EventModal({
|
|||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
aria-label={t("events.duplicate")}
|
aria-label={t("events.duplicate")}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
|||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={selectedIndices.size === 0}
|
disabled={selectedIndices.size === 0}
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4 mr-1" />
|
<Check className="w-4 h-4 me-1" />
|
||||||
{t("import_button")} ({selectedIndices.size})
|
{t("import_button")} ({selectedIndices.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
|||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
{isEdit ? t("saving") : t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, Fragment } from "react";
|
import { useState, useMemo, Fragment } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
getISOWeek, getWeek, format,
|
||||||
isSameDay, isSameMonth, isToday, format,
|
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
|
|
||||||
const MONTH_LABELS = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MiniCalendarProps {
|
interface MiniCalendarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
displayMonth: Date;
|
displayMonth: Date;
|
||||||
@@ -40,17 +34,25 @@ export function MiniCalendar({
|
|||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
}: MiniCalendarProps) {
|
}: MiniCalendarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const {
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
weekStartsOn,
|
||||||
|
dayHeaderKeys,
|
||||||
|
getMonthGridDays,
|
||||||
|
checkIsToday,
|
||||||
|
checkIsSameMonth,
|
||||||
|
checkIsSameDay,
|
||||||
|
formatDayNumber,
|
||||||
|
formatMonthYear,
|
||||||
|
getMonth,
|
||||||
|
getYear,
|
||||||
|
monthLabelKeys,
|
||||||
|
} = useCalendarLocale();
|
||||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(
|
||||||
const monthStart = startOfMonth(displayMonth);
|
() => getMonthGridDays(displayMonth),
|
||||||
const monthEnd = endOfMonth(displayMonth);
|
[displayMonth, getMonthGridDays],
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
);
|
||||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
|
||||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
||||||
}, [displayMonth, weekStart]);
|
|
||||||
|
|
||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -67,20 +69,16 @@ export function MiniCalendar({
|
|||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const dayHeaders = firstDayOfWeek === 0
|
|
||||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
|
||||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
|
||||||
|
|
||||||
// Compute week numbers for each row (one per 7-day chunk)
|
// Compute week numbers for each row (one per 7-day chunk)
|
||||||
const weekNumbers = useMemo(() => {
|
const weekNumbers = useMemo(() => {
|
||||||
if (!showWeekNumbers) return [];
|
if (!showWeekNumbers) return [];
|
||||||
const nums: number[] = [];
|
const nums: number[] = [];
|
||||||
for (let i = 0; i < days.length; i += 7) {
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
// Use the first day of each row to determine the week number
|
// Use the first day of each row to determine the week number
|
||||||
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||||
}
|
}
|
||||||
return nums;
|
return nums;
|
||||||
}, [days, showWeekNumbers, weekStart]);
|
}, [days, showWeekNumbers, weekStartsOn]);
|
||||||
|
|
||||||
const currentYear = getYear(displayMonth);
|
const currentYear = getYear(displayMonth);
|
||||||
const currentMonth = getMonth(displayMonth);
|
const currentMonth = getMonth(displayMonth);
|
||||||
@@ -116,7 +114,7 @@ export function MiniCalendar({
|
|||||||
|
|
||||||
const headerLabel =
|
const headerLabel =
|
||||||
pickerView === "days"
|
pickerView === "days"
|
||||||
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
|
? formatMonthYear(displayMonth)
|
||||||
: pickerView === "months"
|
: pickerView === "months"
|
||||||
? String(currentYear)
|
? String(currentYear)
|
||||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||||
@@ -160,15 +158,15 @@ export function MiniCalendar({
|
|||||||
{showWeekNumbers && (
|
{showWeekNumbers && (
|
||||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||||
)}
|
)}
|
||||||
{dayHeaders.map((d) => (
|
{dayHeaderKeys.map((d) => (
|
||||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||||
{t(`days.${d}`)}
|
{t(`days.${d}`)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{days.map((day, index) => {
|
{days.map((day, index) => {
|
||||||
const inMonth = isSameMonth(day, displayMonth);
|
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = checkIsSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = checkIsToday(day);
|
||||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||||
const isFirstDayOfRow = index % 7 === 0;
|
const isFirstDayOfRow = index % 7 === 0;
|
||||||
|
|
||||||
@@ -193,7 +191,7 @@ export function MiniCalendar({
|
|||||||
selected && "bg-primary text-primary-foreground"
|
selected && "bg-primary text-primary-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{formatDayNumber(day)}
|
||||||
{hasEvent && !selected && (
|
{hasEvent && !selected && (
|
||||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -206,7 +204,7 @@ export function MiniCalendar({
|
|||||||
|
|
||||||
{pickerView === "months" && (
|
{pickerView === "months" && (
|
||||||
<div className="grid grid-cols-3 gap-1 py-1">
|
<div className="grid grid-cols-3 gap-1 py-1">
|
||||||
{MONTH_LABELS.map((label, i) => {
|
{monthLabelKeys.map((labelKey, i) => {
|
||||||
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
||||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||||
return (
|
return (
|
||||||
@@ -220,7 +218,7 @@ export function MiniCalendar({
|
|||||||
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{t(`months.${labelKey}`)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export function TaskModal({
|
|||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => onDelete(task.id)}
|
onClick={() => onDelete(task.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 me-1" />
|
||||||
{t("tasks.delete")}
|
{t("tasks.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function TaskToolbar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showCompleted}
|
checked={showCompleted}
|
||||||
@@ -57,7 +57,7 @@ export function TaskToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button size="sm" onClick={onCreateTask}>
|
<Button size="sm" onClick={onCreateTask}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("tasks.create")}
|
{t("tasks.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
|
|||||||
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends media: null when an existing photo is removed', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const contactWithPhoto: ContactCard = {
|
||||||
|
...existingContact,
|
||||||
|
media: {
|
||||||
|
photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<ContactForm contact={contactWithPhoto} onSave={onSave} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('remove_photo'));
|
||||||
|
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedData = onSave.mock.calls[0][0];
|
||||||
|
expect(savedData.media).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('submits form data correctly', async () => {
|
it('submits form data correctly', async () => {
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||||
@@ -81,4 +102,98 @@ describe('ContactForm', () => {
|
|||||||
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
key={email.id}
|
key={email.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenEmail(email)}
|
onClick={() => handleOpenEmail(email)}
|
||||||
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<Avatar name={sender.name} email={sender.address} size="sm" />
|
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
key={event.id}
|
key={event.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenEvent(event)}
|
onClick={() => handleOpenEvent(event)}
|
||||||
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||||
{formatEventTime(event)}
|
{formatEventTime(event)}
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { ContactActivity } from "./contact-activity";
|
import { ContactActivity } from "./contact-activity";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
|
||||||
import type { CertificateInfo } from "@/lib/smime/types";
|
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { exportContact } from "./contact-export";
|
import { exportContact } from "./contact-export";
|
||||||
import { printContact } from "./contact-print";
|
import { printContact } from "./contact-print";
|
||||||
@@ -119,49 +116,9 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
|
|
||||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const smimeStore = useSmimeStore();
|
|
||||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
|
||||||
|
|
||||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contact) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const parseCerts = async () => {
|
|
||||||
const results = new Map<number, CertificateInfo>();
|
|
||||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
|
||||||
const key = cryptoKeys[i];
|
|
||||||
if (typeof key.uri !== 'string') continue;
|
|
||||||
try {
|
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
|
||||||
if (key.uri.startsWith('data:')) {
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
|
||||||
if (commaIdx === -1) continue;
|
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
|
||||||
const binary = atob(b64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
|
||||||
derBytes = bytes.buffer;
|
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
|
||||||
derBytes = key.uri;
|
|
||||||
}
|
|
||||||
if (!derBytes) continue;
|
|
||||||
const cert = parseCertificatePemOrDer(derBytes);
|
|
||||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
|
||||||
const info = await extractCertificateInfo(cert, der);
|
|
||||||
if (!cancelled) results.set(i, info);
|
|
||||||
} catch { /* skip unparseable keys */ }
|
|
||||||
}
|
|
||||||
if (!cancelled) setParsedCerts(results);
|
|
||||||
};
|
|
||||||
if (cryptoKeys.length > 0) {
|
|
||||||
parseCerts();
|
|
||||||
} else {
|
|
||||||
setParsedCerts(new Map());
|
|
||||||
}
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||||
@@ -207,30 +164,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||||
|
|
||||||
const handleImportContactCert = async (keyIndex: number) => {
|
|
||||||
const key = cryptoKeys[keyIndex];
|
|
||||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
|
||||||
try {
|
|
||||||
let derBytes: ArrayBuffer | string;
|
|
||||||
if (key.uri.startsWith('data:')) {
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
|
||||||
if (commaIdx === -1) return;
|
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
|
||||||
const binary = atob(b64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
|
||||||
derBytes = bytes.buffer;
|
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
|
||||||
derBytes = key.uri;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
|
||||||
toast.success(t("detail.cert_imported"));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||||
@@ -238,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
|
|
||||||
const hasNickname = nicknames.length > 0;
|
const hasNickname = nicknames.length > 0;
|
||||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
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 hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||||
@@ -268,7 +203,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
onClick={onCompose}
|
onClick={onCompose}
|
||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 mr-1" />
|
<Send className="w-4 h-4 me-1" />
|
||||||
{t("detail.compose_email")}
|
{t("detail.compose_email")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -277,12 +212,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
href={`tel:${phone}`}
|
href={`tel:${phone}`}
|
||||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<Phone className="w-4 h-4 mr-1" />
|
<Phone className="w-4 h-4 me-1" />
|
||||||
{t("context_menu.call")}
|
{t("context_menu.call")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("form.edit_title")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||||
@@ -493,59 +428,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
{cryptoKeys.length > 0 && (
|
{cryptoKeys.length > 0 && (
|
||||||
<Section title={t("detail.crypto_keys")}>
|
<Section title={t("detail.crypto_keys")}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{cryptoKeys.map((key, i) => {
|
{cryptoKeys.map((key, i) => (
|
||||||
const certInfo = parsedCerts.get(i);
|
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
<div className="flex items-start gap-2 text-sm break-all">
|
||||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||||
: false;
|
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
{key.uri}
|
||||||
return (
|
</a>
|
||||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
|
||||||
{certInfo ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isExpired ? (
|
|
||||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
|
||||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
|
||||||
<p>
|
|
||||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
|
||||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
|
||||||
</p>
|
|
||||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
|
||||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
|
||||||
</div>
|
|
||||||
{!alreadyImported && (
|
|
||||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
|
||||||
<Download className="w-3 h-3 mr-1" />
|
|
||||||
{t("detail.import_to_smime")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{alreadyImported && (
|
|
||||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-2 text-sm break-all">
|
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
|
||||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
|
||||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
||||||
{key.uri}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
@@ -677,7 +573,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
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) => {
|
{items.map((item, i) => {
|
||||||
if (item.separator) {
|
if (item.separator) {
|
||||||
@@ -693,7 +589,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
||||||
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full text-left",
|
"flex items-center gap-2 w-full text-start",
|
||||||
collapsible ? "cursor-pointer" : "cursor-default"
|
collapsible ? "cursor-pointer" : "cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={() => collapsible && setOpen(!open)}
|
onClick={() => collapsible && setOpen(!open)}
|
||||||
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
|
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(() => {
|
const [jobTitle, setJobTitle] = useState(() => {
|
||||||
if (contact?.titles) {
|
if (contact?.titles) {
|
||||||
const t = Object.values(contact.titles).find(t => t.kind !== "role");
|
const t = Object.values(contact.titles).find(t => t.kind !== "role");
|
||||||
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
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"));
|
setError(t("name_required"));
|
||||||
return;
|
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.
|
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||||
const nameComponents = [];
|
const nameComponents = [];
|
||||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
if (!isOrg) {
|
||||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.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" }> = {};
|
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||||
@@ -525,14 +546,25 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set mediaVluae to null if we are removing media, so the server removes it
|
||||||
|
const hadMedia = !!contact?.media && Object.keys(contact.media).length > 0;
|
||||||
|
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> = {
|
const data: Partial<ContactCard> = {
|
||||||
name: { components: nameComponents, isOrdered: true },
|
name: nameValue,
|
||||||
|
...(kindValue ? { kind: kindValue } : {}),
|
||||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||||
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
||||||
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
||||||
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
||||||
organizations: organization.trim()
|
organizations: orgName
|
||||||
? { o0: { name: organization.trim(), units: orgUnits } }
|
? { o0: { name: orgName, units: orgUnits } }
|
||||||
: undefined,
|
: undefined,
|
||||||
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
||||||
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
|
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
|
||||||
@@ -551,7 +583,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.trim() || undefined,
|
schedulingUri: schedulingUri.trim() || undefined,
|
||||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||||
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
media: mediaValue as Record<string, ContactMedia> | undefined,
|
||||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -565,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() || "";
|
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -656,38 +688,81 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormSection icon={User} title={t("section_identity")}>
|
<FormSection icon={User} title={t("section_identity")}>
|
||||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
|
||||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
{[
|
||||||
</div>
|
{ org: false, label: t("type_person"), icon: User },
|
||||||
<div>
|
{ org: true, label: t("type_organization"), icon: Building },
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
].map(({ org, label, icon: Icon }) => (
|
||||||
{t("given_name")} <span className="text-red-500">*</span>
|
<button
|
||||||
</label>
|
key={label}
|
||||||
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
|
type="button"
|
||||||
</div>
|
role="radio"
|
||||||
<div>
|
aria-checked={isOrg === org}
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
onClick={() => setIsOrg(org)}
|
||||||
{t("surname")} <span className="text-red-500">*</span>
|
className={cn(
|
||||||
</label>
|
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
|
||||||
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
|
isOrg === org
|
||||||
</div>
|
? "bg-primary text-primary-foreground"
|
||||||
<div>
|
: "text-muted-foreground hover:text-foreground"
|
||||||
<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" />
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
{isOrg ? (
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
|
<div>
|
||||||
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
|
<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>
|
||||||
<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 className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
</div>
|
<div>
|
||||||
</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>
|
</FormSection>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
@@ -731,12 +806,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{emailErrors[i] && (
|
{emailErrors[i] && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_email")}
|
{t("add_email")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -794,19 +869,22 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_phone")}
|
{t("add_phone")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Work & Organization */}
|
{/* 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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
{/* In organization mode the org name is the card's identity, edited above. */}
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
{!isOrg && (
|
||||||
<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("organization")}</label>
|
||||||
|
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
|
<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")} />
|
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
|
||||||
@@ -850,7 +928,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_address")}
|
{t("add_address")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_online_service")}
|
{t("add_online_service")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -911,7 +989,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_anniversary")}
|
{t("add_anniversary")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -952,7 +1030,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_personal_info")}
|
{t("add_personal_info")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1140,7 +1218,7 @@ function CategoryComboBox({
|
|||||||
<button
|
<button
|
||||||
key={kw}
|
key={kw}
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
@@ -1150,7 +1228,7 @@ function CategoryComboBox({
|
|||||||
{canAddNew && (
|
{canAddNew && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
|
||||||
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ContactGroupDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("form.edit_title")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -106,7 +106,7 @@ export function ContactGroupDetail({
|
|||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
className="flex items-center gap-3 flex-1 min-w-0 text-start"
|
||||||
onClick={() => onSelectMember(member.id)}
|
onClick={() => onSelectMember(member.id)}
|
||||||
>
|
>
|
||||||
<Avatar name={mName} email={mEmail} size="sm" />
|
<Avatar name={mName} email={mEmail} size="sm" />
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function ContactGroupForm({
|
|||||||
placeholder={t("groups.search_members")}
|
placeholder={t("groups.search_members")}
|
||||||
value={memberSearch}
|
value={memberSearch}
|
||||||
onChange={(e) => setMemberSearch(e.target.value)}
|
onChange={(e) => setMemberSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="ps-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function ContactGroupForm({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleMember(contact.id)}
|
onClick={() => toggleMember(contact.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors",
|
||||||
"hover:bg-muted",
|
"hover:bg-muted",
|
||||||
isSelected && "bg-primary/5"
|
isSelected && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function ContactGroupList({
|
|||||||
<div className={cn("flex flex-col", className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div className="px-4 py-2 border-b border-border">
|
||||||
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("groups.create")}
|
{t("groups.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@ export function ContactGroupList({
|
|||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectGroup(group.id)}
|
onClick={() => onSelectGroup(group.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center px-4 text-left transition-colors",
|
"w-full flex items-center px-4 text-start transition-colors",
|
||||||
"hover:bg-muted",
|
"hover:bg-muted",
|
||||||
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function ContactImportDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSelect(idx)}
|
onClick={() => toggleSelect(idx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
|
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted",
|
||||||
isSelected && "bg-primary/5"
|
isSelected && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export function ContactList({
|
|||||||
placeholder={t("search_placeholder")}
|
placeholder={t("search_placeholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -359,7 +359,7 @@ export function ContactList({
|
|||||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||||
className="h-7 px-2 text-xs"
|
className="h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
<RotateCcw className="w-3 h-3 me-1" />
|
||||||
{t("filters.clear")}
|
{t("filters.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -479,11 +479,11 @@ export function ContactList({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
||||||
<Users className="w-3.5 h-3.5 mr-1" />
|
<Users className="w-3.5 h-3.5 me-1" />
|
||||||
{t("bulk.add_to_group")}
|
{t("bulk.add_to_group")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
||||||
<Download className="w-3.5 h-3.5 mr-1" />
|
<Download className="w-3.5 h-3.5 me-1" />
|
||||||
{t("bulk.export")}
|
{t("bulk.export")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -492,7 +492,7 @@ export function ContactList({
|
|||||||
onClick={onBulkDelete}
|
onClick={onBulkDelete}
|
||||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
<Trash2 className="w-3.5 h-3.5 me-1" />
|
||||||
{t("bulk.delete")}
|
{t("bulk.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||||
@@ -526,7 +526,7 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
{activeFilters > 0 && (
|
{activeFilters > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
<RotateCcw className="w-3.5 h-3.5 me-1" />
|
||||||
{t("filters.clear")}
|
{t("filters.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +538,7 @@ export function ContactList({
|
|||||||
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
||||||
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
||||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
<UserPlus className="w-4 h-4 me-1.5" />
|
||||||
{t("create_new")}
|
{t("create_new")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -284,17 +284,17 @@ export function ContactsSidebar({
|
|||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
{t("create_new")}
|
{t("create_new")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
>
|
>
|
||||||
<UsersRound className="w-4 h-4" />
|
<UsersRound className="w-4 h-4" />
|
||||||
@@ -302,7 +302,7 @@ export function ContactsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
{onCreateAddressBook && (
|
{onCreateAddressBook && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||||
>
|
>
|
||||||
<BookPlus className="w-4 h-4" />
|
<BookPlus className="w-4 h-4" />
|
||||||
@@ -311,7 +311,7 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
{onImport && (
|
{onImport && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onImport(); }}
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
@@ -338,7 +338,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{t("tabs.all")}</span>
|
<span className="truncate">{t("tabs.all")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{individuals.length}
|
{individuals.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -355,7 +355,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(sectionKey)}
|
onClick={() => toggleSection(sectionKey)}
|
||||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
className="flex items-center gap-1 flex-1 min-w-0 text-start"
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -369,7 +369,7 @@ export function ContactsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="pl-2">
|
<div className="ps-2">
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
@@ -418,7 +418,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("addressBooks")}
|
onClick={() => toggleSection("addressBooks")}
|
||||||
className="flex items-center gap-1 flex-1 text-left"
|
className="flex items-center gap-1 flex-1 text-start"
|
||||||
>
|
>
|
||||||
{collapsed.addressBooks ? (
|
{collapsed.addressBooks ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -461,7 +461,7 @@ export function ContactsSidebar({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("groups")}
|
onClick={() => toggleSection("groups")}
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
|
||||||
>
|
>
|
||||||
{collapsed.groups ? (
|
{collapsed.groups ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -483,7 +483,7 @@ export function ContactsSidebar({
|
|||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -492,7 +492,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Users className="w-4 h-4 flex-shrink-0" />
|
<Users className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{getContactDisplayName(group)}</span>
|
<span className="truncate">{getContactDisplayName(group)}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{memberCount}
|
{memberCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -505,7 +505,7 @@ export function ContactsSidebar({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("categories")}
|
onClick={() => toggleSection("categories")}
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
|
||||||
>
|
>
|
||||||
{collapsed.categories ? (
|
{collapsed.categories ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -523,7 +523,7 @@ export function ContactsSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onSelectCategory("uncategorized")}
|
onClick={() => onSelectCategory("uncategorized")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||||
activeCategory === "uncategorized"
|
activeCategory === "uncategorized"
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -532,7 +532,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||||
<span className="truncate italic">{t("no_category")}</span>
|
<span className="truncate italic">{t("no_category")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{uncategorizedCount}
|
{uncategorizedCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -561,7 +561,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
className="flex items-center gap-1 flex-1 min-w-0 text-start"
|
||||||
>
|
>
|
||||||
{collapsed[`shared-${group.accountId}`] ? (
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -779,7 +779,7 @@ function CategoryItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
@@ -789,7 +789,7 @@ function CategoryItem({
|
|||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span className="truncate">{keyword}</span>
|
<span className="truncate">{keyword}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -847,7 +847,7 @@ function AddressBookItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
@@ -858,11 +858,11 @@ function AddressBookItem({
|
|||||||
<Book className="w-4 h-4 flex-shrink-0" />
|
<Book className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{book.name}</span>
|
<span className="truncate">{book.name}</span>
|
||||||
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
||||||
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
|
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs text-muted-foreground tabular-nums",
|
"text-xs text-muted-foreground tabular-nums",
|
||||||
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
|
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
|
||||||
)}>
|
)}>
|
||||||
{contactCount}
|
{contactCount}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
|
|||||||
mocks.clientMock,
|
mocks.clientMock,
|
||||||
'event-8',
|
'event-8',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
replyTo: { imip: 'mailto:organizer@example.com' },
|
// The stored event lacks an ORGANIZER, so the RSVP repair writes
|
||||||
|
// organizerCalendarAddress (replyTo is retired in jscalendarbis).
|
||||||
|
organizerCalendarAddress: 'mailto:organizer@example.com',
|
||||||
participants: expect.objectContaining({
|
participants: expect.objectContaining({
|
||||||
attendee: expect.objectContaining({
|
attendee: expect.objectContaining({
|
||||||
participationStatus: 'accepted',
|
participationStatus: 'accepted',
|
||||||
|
|||||||
@@ -1,123 +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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => {
|
|||||||
return { useAccountStore: hook };
|
return { useAccountStore: hook };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@/stores/smime-store', () => {
|
|
||||||
const state = {
|
|
||||||
certs: [],
|
|
||||||
signingEnabled: false,
|
|
||||||
encryptionEnabled: false,
|
|
||||||
defaultSigningCertId: null,
|
|
||||||
defaultEncryptionCertId: 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 { useSmimeStore: hook };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('@/stores/email-store', () => {
|
vi.mock('@/stores/email-store', () => {
|
||||||
const state = {
|
const state = {
|
||||||
draftSaveEnabled: false,
|
draftSaveEnabled: false,
|
||||||
@@ -151,6 +136,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
|||||||
getRecipientSuggestions: { call: async () => [] },
|
getRecipientSuggestions: { call: async () => [] },
|
||||||
onSend: { call: async () => [] },
|
onSend: { call: async () => [] },
|
||||||
beforeSend: { call: async () => [] },
|
beforeSend: { call: async () => [] },
|
||||||
|
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||||
},
|
},
|
||||||
contactHooks: {
|
contactHooks: {
|
||||||
search: { call: async () => [] },
|
search: { call: async () => [] },
|
||||||
@@ -163,7 +149,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
|||||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
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', () => ({
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
}));
|
}));
|
||||||
@@ -172,12 +161,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
|||||||
getPlainTextSignature: () => '',
|
getPlainTextSignature: () => '',
|
||||||
}));
|
}));
|
||||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
|
||||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
|
||||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
|
||||||
buildMimeMessage: () => null,
|
|
||||||
wrapCmsAsSmimeMessage: () => null,
|
|
||||||
}));
|
|
||||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||||
vi.mock('@/components/email/quoted-html', () => ({
|
vi.mock('@/components/email/quoted-html', () => ({
|
||||||
buildQuotedHtmlBlock: () => '',
|
buildQuotedHtmlBlock: () => '',
|
||||||
@@ -247,8 +230,10 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
const dt = new MockDataTransfer();
|
const dt = new MockDataTransfer();
|
||||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
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'));
|
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 () => {
|
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||||
@@ -262,7 +247,7 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
const dt = new MockDataTransfer();
|
const dt = new MockDataTransfer();
|
||||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
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 () => {
|
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||||
@@ -361,4 +346,121 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
const ccLabel = await screen.findByText('cc_label');
|
const ccLabel = await screen.findByText('cc_label');
|
||||||
expect(ccLabel).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,21 +66,6 @@ vi.mock('@/stores/account-store', () => {
|
|||||||
return { useAccountStore: hook };
|
return { useAccountStore: hook };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@/stores/smime-store', () => {
|
|
||||||
const state = {
|
|
||||||
certs: [],
|
|
||||||
signingEnabled: false,
|
|
||||||
encryptionEnabled: false,
|
|
||||||
defaultSigningCertId: null,
|
|
||||||
defaultEncryptionCertId: 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 { useSmimeStore: hook };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('@/stores/email-store', () => {
|
vi.mock('@/stores/email-store', () => {
|
||||||
const state = {
|
const state = {
|
||||||
draftSaveEnabled: false,
|
draftSaveEnabled: false,
|
||||||
@@ -150,6 +135,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
|||||||
getRecipientSuggestions: { call: async () => [] },
|
getRecipientSuggestions: { call: async () => [] },
|
||||||
onSend: { call: async () => [] },
|
onSend: { call: async () => [] },
|
||||||
beforeSend: { call: async () => [] },
|
beforeSend: { call: async () => [] },
|
||||||
|
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||||
},
|
},
|
||||||
contactHooks: {
|
contactHooks: {
|
||||||
search: { call: async () => [] },
|
search: { call: async () => [] },
|
||||||
@@ -162,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
|||||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
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', () => ({
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
}));
|
}));
|
||||||
@@ -171,12 +160,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
|||||||
getPlainTextSignature: () => '',
|
getPlainTextSignature: () => '',
|
||||||
}));
|
}));
|
||||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
|
||||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
|
||||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
|
||||||
buildMimeMessage: () => null,
|
|
||||||
wrapCmsAsSmimeMessage: () => null,
|
|
||||||
}));
|
|
||||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||||
vi.mock('@/components/email/quoted-html', () => ({
|
vi.mock('@/components/email/quoted-html', () => ({
|
||||||
buildQuotedHtmlBlock: () => '',
|
buildQuotedHtmlBlock: () => '',
|
||||||
|
|||||||
@@ -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,39 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { SelectableAvatar } from '../selectable-avatar';
|
||||||
|
|
||||||
|
// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
|
||||||
|
// care about the selection wrapper behaviour here.
|
||||||
|
vi.mock('@/components/ui/avatar', () => ({
|
||||||
|
Avatar: (props: { name?: string }) => <span data-testid="avatar">{props.name}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SelectableAvatar', () => {
|
||||||
|
it('renders the wrapped avatar', () => {
|
||||||
|
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />);
|
||||||
|
expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onToggle and stops propagation when the avatar is clicked', () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<div onClick={onRowClick}>
|
||||||
|
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('checkbox'));
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
// Clicking the avatar must not bubble up to open/select the row.
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the checked state via aria-checked', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
} from 'lucide-react';
|
||||||
import { useTranslations, useFormatter } from 'next-intl';
|
import { useTranslations, useFormatter } from 'next-intl';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
import { isDocumentRTL } from '@/i18n/direction';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [showCalendarPicker, setShowCalendarPicker] = 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 pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||||
@@ -582,7 +583,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
} else {
|
} else {
|
||||||
await updateEvent(client, eventForRsvp.id, {
|
await updateEvent(client, eventForRsvp.id, {
|
||||||
participants: repairedParticipants,
|
participants: repairedParticipants,
|
||||||
replyTo: replyToForRsvp ?? undefined,
|
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
|
||||||
|
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
|
||||||
|
// Only repair a missing organizer - attendees may not modify it.
|
||||||
|
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
|
||||||
|
? { organizerCalendarAddress: replyToForRsvp.imip }
|
||||||
|
: {}),
|
||||||
}, true);
|
}, true);
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
@@ -1021,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
if (pickerTriggerRef.current) {
|
if (pickerTriggerRef.current) {
|
||||||
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
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);
|
setShowCalendarPicker(true);
|
||||||
}}
|
}}
|
||||||
@@ -1036,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
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">
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
{t('select_calendar')}
|
{t('select_calendar')}
|
||||||
@@ -1048,7 +1058,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
setShowCalendarPicker(false);
|
setShowCalendarPicker(false);
|
||||||
handleImport(cal.id);
|
handleImport(cal.id);
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
|
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
@@ -1090,7 +1100,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ms-auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+622
-401
File diff suppressed because it is too large
Load Diff
@@ -17,12 +17,12 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Star,
|
Star,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Tag,
|
Tag,
|
||||||
X,
|
|
||||||
Check,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
Send,
|
Send,
|
||||||
File,
|
File,
|
||||||
@@ -32,10 +32,12 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
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 {
|
interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -57,11 +59,13 @@ interface EmailContextMenuProps {
|
|||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
|
onTogglePinned?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMoveToMailbox?: (mailboxId: string) => void;
|
onMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
@@ -96,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({
|
export function EmailContextMenu({
|
||||||
email,
|
email,
|
||||||
position,
|
position,
|
||||||
@@ -124,11 +114,13 @@ export function EmailContextMenu({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
@@ -145,24 +137,20 @@ export function EmailContextMenu({
|
|||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const tEmailViewer = useTranslations("email_viewer");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentTagIds = getEmailTagIds(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||||
|
// in Sent, Drafts and Scheduled.
|
||||||
|
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
|
||||||
const isScheduled = email.isScheduled === true;
|
const isScheduled = email.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
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
|
// Build mailbox tree for move-to submenu with proper hierarchy
|
||||||
const moveTargetIds = new Set(
|
const moveTargetIds = new Set(
|
||||||
mailboxes
|
mailboxes
|
||||||
@@ -269,6 +257,12 @@ export function EmailContextMenu({
|
|||||||
onClick={() => handleAction(onForward!)}
|
onClick={() => handleAction(onForward!)}
|
||||||
disabled={!onForward}
|
disabled={!onForward}
|
||||||
/>
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Paperclip}
|
||||||
|
label={tEmailViewer("forward_as_attachment")}
|
||||||
|
onClick={() => handleAction(onForwardAsAttachment!)}
|
||||||
|
disabled={!onForwardAsAttachment || !email.blobId}
|
||||||
|
/>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -287,6 +281,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label={t("delete")}
|
label={t("delete")}
|
||||||
|
testId="ctx-delete"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||||
}
|
}
|
||||||
@@ -298,7 +293,7 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Move to submenu */}
|
{/* Move to submenu */}
|
||||||
{moveTree.length > 0 && (
|
{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[]) => {
|
const renderNodes = (nodes: MailboxNode[]) => {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
@@ -311,6 +306,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={nodeLabel}
|
label={nodeLabel}
|
||||||
|
testId={`move-to:${node.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
@@ -326,7 +322,7 @@ export function EmailContextMenu({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.children.length > 0 && (
|
{node.children.length > 0 && (
|
||||||
<div className="pl-4">
|
<div className="ps-4">
|
||||||
{renderNodes(node.children)}
|
{renderNodes(node.children)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -349,58 +345,48 @@ export function EmailContextMenu({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
|
||||||
|
{!showBatchActions && onTogglePinned && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={isPinned ? PinOff : Pin}
|
||||||
|
label={isPinned ? t("unpin") : t("pin")}
|
||||||
|
onClick={() => handleAction(onTogglePinned)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||||
{colorOptions.map((option) => {
|
<div className="w-56 max-w-[18rem]">
|
||||||
const isActive = currentColors.includes(option.value);
|
<TagPicker
|
||||||
return (
|
selectedIds={currentTagIds}
|
||||||
<button
|
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||||
key={option.value}
|
/>
|
||||||
role="menuitem"
|
</div>
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
|
||||||
className={cn(
|
|
||||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
|
||||||
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>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
|
||||||
|
{spamApplicable && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
{/* Spam - contextual based on folder */}
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(
|
handleAction(
|
||||||
showBatchActions
|
showBatchActions
|
||||||
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
||||||
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
||||||
destructive={!isInJunkFolder}
|
destructive={!isInJunkFolder}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
@@ -408,6 +394,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={isUnread ? MailOpen : Mail}
|
icon={isUnread ? MailOpen : Mail}
|
||||||
label={isUnread ? t("mark_read") : t("mark_unread")}
|
label={isUnread ? t("mark_read") : t("mark_unread")}
|
||||||
|
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ interface EmailHoverActionsProps {
|
|||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||||
// the spam quick-action flips to "not spam".
|
// the spam quick-action flips to "not spam".
|
||||||
isInJunk?: boolean;
|
isInJunk?: boolean;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
|
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
|
||||||
|
spamApplicable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_CONFIG: Record<HoverAction, {
|
const ACTION_CONFIG: Record<HoverAction, {
|
||||||
@@ -74,10 +76,11 @@ export function EmailHoverActions({
|
|||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
isInJunk = false,
|
isInJunk = false,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
|
spamApplicable = true,
|
||||||
}: EmailHoverActionsProps) {
|
}: EmailHoverActionsProps) {
|
||||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
@@ -109,7 +112,7 @@ export function EmailHoverActions({
|
|||||||
onArchive?.();
|
onArchive?.();
|
||||||
break;
|
break;
|
||||||
case "tag":
|
case "tag":
|
||||||
onSetColorTag?.(null);
|
onSetTag?.(null);
|
||||||
break;
|
break;
|
||||||
case "spam":
|
case "spam":
|
||||||
if (isInJunk) onUndoSpam?.();
|
if (isInJunk) onUndoSpam?.();
|
||||||
@@ -121,6 +124,7 @@ export function EmailHoverActions({
|
|||||||
const actionButtons = hoverActions.map((actionId) => {
|
const actionButtons = hoverActions.map((actionId) => {
|
||||||
const config = ACTION_CONFIG[actionId];
|
const config = ACTION_CONFIG[actionId];
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
if (actionId === "spam" && !spamApplicable) return null;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
// In a junk context the spam action becomes "not spam".
|
// In a junk context the spam action becomes "not spam".
|
||||||
@@ -177,16 +181,17 @@ export function EmailHoverActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
className="absolute end-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
className={cn(
|
||||||
style={{
|
"w-8 h-full",
|
||||||
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
"[mask-image:linear-gradient(to_right,transparent,black)] [-webkit-mask-image:linear-gradient(to_right,transparent,black)]",
|
||||||
maskImage: "linear-gradient(to right, transparent, black)",
|
"rtl:[mask-image:linear-gradient(to_left,transparent,black)] rtl:[-webkit-mask-image:linear-gradient(to_left,transparent,black)]",
|
||||||
}}
|
hoverBackgroundClassName,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
<div className={cn("flex items-center gap-0.5 h-full pe-3 ps-0.5", hoverBackgroundClassName)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,333 +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 { Avatar } from "@/components/ui/avatar";
|
|
||||||
import { Paperclip, Star, 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 { 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 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 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 = 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();
|
|
||||||
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 left-0.5 top-1/2 -translate-y-1/2">
|
|
||||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
{density !== 'extra-compact' && (
|
|
||||||
<Avatar
|
|
||||||
name={sender?.name}
|
|
||||||
email={sender?.email}
|
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
|
||||||
className="flex-shrink-0 shadow-sm"
|
|
||||||
disableImages={hideJunkAvatarImages}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
{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">
|
|
||||||
{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'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { ThreadListItem } from "./thread-list-item";
|
import { ThreadListItem } from "./thread-list-item";
|
||||||
import { EmailContextMenu } from "./email-context-menu";
|
import { EmailContextMenu } from "./email-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
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 { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
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 { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||||
import { SearchChips } from "@/components/search/search-chips";
|
import { SearchChips } from "@/components/search/search-chips";
|
||||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||||
|
|
||||||
@@ -33,11 +34,13 @@ interface EmailListProps {
|
|||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
|
onForwardAsAttachment?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
|
onTogglePinned?: (email: Email) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (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;
|
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||||
onMarkAsSpam?: (email: Email) => void;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
@@ -62,11 +65,13 @@ export function EmailList({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
@@ -128,10 +133,20 @@ export function EmailList({
|
|||||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
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 { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
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 density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -189,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 () => {
|
const handleBatchDelete = async () => {
|
||||||
if (!client || isProcessing) return;
|
if (!client || isProcessing) return;
|
||||||
|
|
||||||
@@ -312,6 +343,7 @@ export function EmailList({
|
|||||||
}, [density, isFocusedMailLayout, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TagDisplayContext.Provider value={tagDisplay}>
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
{/* Batch Actions Toolbar */}
|
{/* Batch Actions Toolbar */}
|
||||||
<div
|
<div
|
||||||
@@ -355,6 +387,22 @@ export function EmailList({
|
|||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -415,9 +463,9 @@ export function EmailList({
|
|||||||
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
<Loader2 className="w-3 h-3 animate-spin me-1" />
|
||||||
) : (
|
) : (
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
<Trash2 className="w-3 h-3 me-1" />
|
||||||
)}
|
)}
|
||||||
{t('empty_folder.button')}
|
{t('empty_folder.button')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -507,7 +555,7 @@ export function EmailList({
|
|||||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetTag={onSetTag}
|
||||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -534,9 +582,9 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
{contextMenu.data && (
|
{contextMenuEmail && (
|
||||||
<EmailContextMenu
|
<EmailContextMenu
|
||||||
email={contextMenu.data}
|
email={contextMenuEmail}
|
||||||
position={contextMenu.position}
|
position={contextMenu.position}
|
||||||
isOpen={contextMenu.isOpen}
|
isOpen={contextMenu.isOpen}
|
||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
@@ -544,23 +592,25 @@ export function EmailList({
|
|||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
currentMailboxRole={effectiveMailboxRole}
|
currentMailboxRole={effectiveMailboxRole}
|
||||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||||
selectedCount={selectedEmailIds.size}
|
selectedCount={selectedEmailIds.size}
|
||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenuEmail!)}
|
||||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenuEmail!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
||||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
onDelete={() => onDelete?.(contextMenuEmail!)}
|
||||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
onArchive={() => onArchive?.(contextMenuEmail!)}
|
||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
||||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||||
|
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||||
|
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchArchive={async () => {
|
onBatchArchive={async () => {
|
||||||
@@ -607,5 +657,6 @@ export function EmailList({
|
|||||||
|
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
|
</TagDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+493
-1334
File diff suppressed because it is too large
Load Diff
@@ -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.
|
// HTML, so parseHTML can recognise it on the way back in.
|
||||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
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
|
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
* 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");
|
const dom = document.createElement("div");
|
||||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||||
dom.className = "quoted-html-island";
|
dom.className = "quoted-html-island";
|
||||||
dom.style.cssText =
|
dom.style.cssText = QUOTE_BAR_STYLE;
|
||||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
|
||||||
|
|
||||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
// 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).
|
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||||
*/
|
*/
|
||||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface ReadReceiptBannerProps {
|
interface ReadReceiptBannerProps {
|
||||||
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
|
|||||||
const t = useTranslations('email_viewer.read_receipt');
|
const t = useTranslations('email_viewer.read_receipt');
|
||||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||||
|
|
||||||
|
// Matches the host's "External Content" banner row: a round tinted icon chip,
|
||||||
|
// an uppercase eyebrow, a foreground message, and neutral bordered actions.
|
||||||
if (state === 'sent') {
|
if (state === 'sent') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 py-1">
|
||||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<span>{t('sent')}</span>
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('sent')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
<div className="flex items-start gap-3 py-1">
|
||||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<span className="text-foreground">{t('prompt')}</span>
|
<MailCheck className="w-5 h-5" />
|
||||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<button
|
<div>
|
||||||
onClick={async () => {
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
setState('sending');
|
Read receipt
|
||||||
try {
|
</div>
|
||||||
await onSend();
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
setState('sent');
|
{t('prompt')}
|
||||||
} catch {
|
</div>
|
||||||
setState('idle');
|
<div className="text-xs text-muted-foreground break-all">
|
||||||
}
|
Requested by <span className="text-foreground/80">{requestedBy}</span>
|
||||||
}}
|
</div>
|
||||||
disabled={state === 'sending'}
|
</div>
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
>
|
<button
|
||||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
onClick={async () => {
|
||||||
{t('send')}
|
setState('sending');
|
||||||
</button>
|
try {
|
||||||
<button
|
await onSend();
|
||||||
onClick={onIgnore}
|
setState('sent');
|
||||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
} catch {
|
||||||
>
|
setState('idle');
|
||||||
{t('ignore')}
|
}
|
||||||
</button>
|
}}
|
||||||
|
disabled={state === 'sending'}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{state === 'sending' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MailCheck className="w-3.5 h-3.5" />}
|
||||||
|
{t('send')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onIgnore}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
{t('ignore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayLabel || name || email}
|
<bdi>{displayLabel || name || email}</bdi>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
@@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
{onViewContact && (
|
{onViewContact && (
|
||||||
<button
|
<button
|
||||||
onClick={handleViewContact}
|
onClick={handleViewContact}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ms-auto"
|
||||||
title={contact ? "View contact" : "View details"}
|
title={contact ? "View contact" : "View details"}
|
||||||
>
|
>
|
||||||
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Heading from "@tiptap/extension-heading";
|
|||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import TextAlign from "@tiptap/extension-text-align";
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import { TextDirection } from "@/components/email/text-direction";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import Color from "@tiptap/extension-color";
|
import Color from "@tiptap/extension-color";
|
||||||
import { ResizableImage } from "@/components/email/resizable-image";
|
import { ResizableImage } from "@/components/email/resizable-image";
|
||||||
@@ -19,6 +20,8 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
|||||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||||
import { SignatureBlock } from "@/components/email/signature-block";
|
import { SignatureBlock } from "@/components/email/signature-block";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
AlignLeft,
|
AlignLeft,
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
AlignRight,
|
AlignRight,
|
||||||
|
ArrowLeftRight,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo,
|
||||||
@@ -38,6 +42,7 @@ import {
|
|||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
|
Baseline,
|
||||||
Trash2,
|
Trash2,
|
||||||
Rows3,
|
Rows3,
|
||||||
Columns3,
|
Columns3,
|
||||||
@@ -140,7 +145,16 @@ function ToolbarSeparator() {
|
|||||||
const TABLE_PICKER_ROWS = 6;
|
const TABLE_PICKER_ROWS = 6;
|
||||||
const TABLE_PICKER_COLS = 8;
|
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 }) {
|
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);
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -168,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -183,6 +197,7 @@ export function RichTextEditor({
|
|||||||
hasError,
|
hasError,
|
||||||
onEditorReady,
|
onEditorReady,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
|
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||||
const onImageUploadRef = React.useRef(onImageUpload);
|
const onImageUploadRef = React.useRef(onImageUpload);
|
||||||
onImageUploadRef.current = onImageUpload;
|
onImageUploadRef.current = onImageUpload;
|
||||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||||
@@ -240,6 +255,7 @@ export function RichTextEditor({
|
|||||||
// rich/branded signatures keep their inline styling in the editor and
|
// rich/branded signatures keep their inline styling in the editor and
|
||||||
// in the sent mail (see signature-block.ts).
|
// in the sent mail (see signature-block.ts).
|
||||||
SignatureBlock,
|
SignatureBlock,
|
||||||
|
TextDirection,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -329,8 +345,22 @@ export function RichTextEditor({
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const tToolbar = useTranslations("email_composer.toolbar");
|
||||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!tableMenuOpen) return;
|
if (!tableMenuOpen) return;
|
||||||
@@ -356,45 +386,88 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("bold")}
|
active={editor.isActive("bold")}
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
title="Bold"
|
title={tToolbar("bold")}
|
||||||
>
|
>
|
||||||
<Bold className="w-4 h-4" />
|
<Bold className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("italic")}
|
active={editor.isActive("italic")}
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
title="Italic"
|
title={tToolbar("italic")}
|
||||||
>
|
>
|
||||||
<Italic className="w-4 h-4" />
|
<Italic className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("underline")}
|
active={editor.isActive("underline")}
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
title="Underline"
|
title={tToolbar("underline")}
|
||||||
>
|
>
|
||||||
<UnderlineIcon className="w-4 h-4" />
|
<UnderlineIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("strike")}
|
active={editor.isActive("strike")}
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
title="Strikethrough"
|
title={tToolbar("strikethrough")}
|
||||||
>
|
>
|
||||||
<Strikethrough className="w-4 h-4" />
|
<Strikethrough className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</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 />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("heading", { level: 1 })}
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
title="Heading 1"
|
title={tToolbar("heading_1")}
|
||||||
>
|
>
|
||||||
<Heading1 className="w-4 h-4" />
|
<Heading1 className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("heading", { level: 2 })}
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
title="Heading 2"
|
title={tToolbar("heading_2")}
|
||||||
>
|
>
|
||||||
<Heading2 className="w-4 h-4" />
|
<Heading2 className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -404,28 +477,28 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("bulletList")}
|
active={editor.isActive("bulletList")}
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
title="Bullet List"
|
title={tToolbar("bullet_list")}
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<List className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("orderedList")}
|
active={editor.isActive("orderedList")}
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
title="Ordered List"
|
title={tToolbar("ordered_list")}
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-4 h-4" />
|
<ListOrdered className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("blockquote")}
|
active={editor.isActive("blockquote")}
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
title="Quote"
|
title={tToolbar("quote")}
|
||||||
>
|
>
|
||||||
<Quote className="w-4 h-4" />
|
<Quote className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("codeBlock")}
|
active={editor.isActive("codeBlock")}
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
title="Code Block"
|
title={tToolbar("code_block")}
|
||||||
>
|
>
|
||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -435,31 +508,47 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive({ textAlign: "left" })}
|
active={editor.isActive({ textAlign: "left" })}
|
||||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||||
title="Align Left"
|
title={tToolbar("align_left")}
|
||||||
>
|
>
|
||||||
<AlignLeft className="w-4 h-4" />
|
<AlignLeft className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive({ textAlign: "center" })}
|
active={editor.isActive({ textAlign: "center" })}
|
||||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||||
title="Align Center"
|
title={tToolbar("align_center")}
|
||||||
>
|
>
|
||||||
<AlignCenter className="w-4 h-4" />
|
<AlignCenter className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive({ textAlign: "right" })}
|
active={editor.isActive({ textAlign: "right" })}
|
||||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||||
title="Align Right"
|
title={tToolbar("align_right")}
|
||||||
>
|
>
|
||||||
<AlignRight className="w-4 h-4" />
|
<AlignRight className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
|
{rtlEditingSupport && (
|
||||||
|
<ToolbarButton
|
||||||
|
active={
|
||||||
|
(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cur =
|
||||||
|
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||||
|
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||||
|
}}
|
||||||
|
title={tToolbar("text_direction")}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("link")}
|
active={editor.isActive("link")}
|
||||||
onClick={addLink}
|
onClick={addLink}
|
||||||
title="Link"
|
title={tToolbar("link")}
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -468,71 +557,71 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("table")}
|
active={editor.isActive("table")}
|
||||||
onClick={() => setTableMenuOpen((v) => !v)}
|
onClick={() => setTableMenuOpen((v) => !v)}
|
||||||
title="Table"
|
title={tToolbar("table")}
|
||||||
>
|
>
|
||||||
<TableIcon className="w-4 h-4" />
|
<TableIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
{tableMenuOpen && (
|
{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") ? (
|
{editor.isActive("table") ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
</button>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
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); }}
|
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>
|
</button>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
|
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); }}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -551,7 +640,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||||
title="Clear Formatting"
|
title={tToolbar("clear_formatting")}
|
||||||
>
|
>
|
||||||
<RemoveFormatting className="w-4 h-4" />
|
<RemoveFormatting className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -561,14 +650,14 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().undo().run()}
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
disabled={!editor.can().undo()}
|
disabled={!editor.can().undo()}
|
||||||
title="Undo"
|
title={tToolbar("undo")}
|
||||||
>
|
>
|
||||||
<Undo className="w-4 h-4" />
|
<Undo className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
disabled={!editor.can().redo()}
|
disabled={!editor.can().redo()}
|
||||||
title="Redo"
|
title={tToolbar("redo")}
|
||||||
>
|
>
|
||||||
<Redo className="w-4 h-4" />
|
<Redo className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SelectableAvatarProps = ComponentProps<typeof Avatar> & {
|
||||||
|
/** Whether the underlying message/thread is currently selected. */
|
||||||
|
checked: boolean;
|
||||||
|
/** Toggle selection. The wrapper stops propagation so the row is not opened. */
|
||||||
|
onToggle: () => void;
|
||||||
|
/** Accessible label for the selection control. */
|
||||||
|
selectLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar that doubles as a selection control, Thunderbird-style: clicking the
|
||||||
|
* avatar toggles the message/thread into the current selection instead of
|
||||||
|
* opening it. A check overlay appears on hover (hinting it is clickable) and
|
||||||
|
* stays visible while the row is selected.
|
||||||
|
*/
|
||||||
|
export function SelectableAvatar({
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
selectLabel,
|
||||||
|
className,
|
||||||
|
...avatarProps
|
||||||
|
}: SelectableAvatarProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={selectLabel}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"group/select relative shrink-0 rounded-full outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar {...avatarProps} />
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center rounded-full",
|
||||||
|
"bg-primary text-primary-foreground transition-opacity duration-150",
|
||||||
|
checked ? "opacity-100" : "opacity-0 group-hover/select:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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).
|
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
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
|
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
* *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.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||||
dom.className = "signature-block-island";
|
dom.className = "signature-block-island";
|
||||||
|
|
||||||
|
|
||||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||||
// would otherwise cascade INTO the signature and destroy its layout -
|
// would otherwise cascade INTO the signature and destroy its layout -
|
||||||
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
|
|||||||
const inner = document.createElement("div");
|
const inner = document.createElement("div");
|
||||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||||
inner.contentEditable = "false";
|
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);
|
shadow.appendChild(inner);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
|
|||||||
stopEvent: () => false,
|
stopEvent: () => false,
|
||||||
update: (updatedNode) => {
|
update: (updatedNode) => {
|
||||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
const nextHtml = updatedNode.attrs.html || "";
|
||||||
inner.innerHTML = updatedNode.attrs.html || "";
|
if (nextHtml !== appliedHtml) {
|
||||||
|
appliedHtml = nextHtml;
|
||||||
|
inner.innerHTML = nextHtml;
|
||||||
|
forceLinksToNewTab(inner);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import type { SmimeStatus } from "@/lib/smime/types";
|
|
||||||
|
|
||||||
interface SmimeStatusBannerProps {
|
|
||||||
status: SmimeStatus;
|
|
||||||
onUnlockKey?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
|
|
||||||
|
|
||||||
const variantTone: Record<SmimeVariant, string> = {
|
|
||||||
success: 'bg-success/15 text-success',
|
|
||||||
warning: 'bg-warning/15 text-warning',
|
|
||||||
error: 'bg-destructive/15 text-destructive',
|
|
||||||
info: 'bg-info/15 text-info',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
|
||||||
const t = useTranslations('smime');
|
|
||||||
|
|
||||||
const items: Array<{
|
|
||||||
icon: React.ReactNode;
|
|
||||||
text: string;
|
|
||||||
variant: SmimeVariant;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Encryption status
|
|
||||||
if (status.isEncrypted) {
|
|
||||||
if (status.decryptionError) {
|
|
||||||
if (status.decryptionError === 'locked') {
|
|
||||||
items.push({
|
|
||||||
icon: <Lock className="w-5 h-5" />,
|
|
||||||
text: t('unlock_key_desc'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else if (status.decryptionError === 'no-key') {
|
|
||||||
items.push({
|
|
||||||
icon: <Lock className="w-5 h-5" />,
|
|
||||||
text: t('status_encrypted_no_key'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
icon: <ShieldX className="w-5 h-5" />,
|
|
||||||
text: t('status_encrypted_failed'),
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
icon: <LockOpen className="w-5 h-5" />,
|
|
||||||
text: t('status_encrypted_ok'),
|
|
||||||
variant: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature status
|
|
||||||
if (status.isSigned) {
|
|
||||||
if (status.signatureValid === true) {
|
|
||||||
if (status.selfSigned) {
|
|
||||||
items.push({
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
text: t('status_signed_self_signed'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else if (status.signerEmailMatch === false) {
|
|
||||||
items.push({
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
text: t('status_signed_mismatch'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
icon: <ShieldCheck className="w-5 h-5" />,
|
|
||||||
text: t('status_signed_valid'),
|
|
||||||
variant: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (status.signatureValid === false) {
|
|
||||||
items.push({
|
|
||||||
icon: <ShieldAlert className="w-5 h-5" />,
|
|
||||||
text: status.signatureError || t('status_signed_invalid'),
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsupported S/MIME
|
|
||||||
if (status.unsupportedReason) {
|
|
||||||
items.push({
|
|
||||||
icon: <Info className="w-5 h-5" />,
|
|
||||||
text: t('status_unsupported'),
|
|
||||||
variant: 'info',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-col gap-3 py-1", className)}>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3">
|
|
||||||
<div className={cn(
|
|
||||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
|
||||||
variantTone[item.variant],
|
|
||||||
)}>
|
|
||||||
{item.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
S/MIME
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-foreground break-words">
|
|
||||||
{item.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
|
||||||
<button
|
|
||||||
onClick={onUnlockKey}
|
|
||||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
|
|
||||||
>
|
|
||||||
{t('unlock_key')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user