Compare commits

...
121 Commits
Author SHA1 Message Date
Linus Rath d1c5dba7d7 chore: update version to 1.6.1 2026-05-04 12:34:53 +02:00
Linus Rath 8c50abe221 fix: synchronize mobile submenu view with browser history for better navigation 2026-05-04 12:31:51 +02:00
Linus Rath 07367a8a5d fix: update email viewer styles to improve overflow handling 2026-05-04 12:27:44 +02:00
Linus Rath 1a50788c91 fix: ensure cookieSlot consistency during account updates in auth store 2026-05-04 12:09:58 +02:00
Linus Rath 0e06bfe273 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-04 11:25:49 +02:00
Linus Rath f68e41d81a fix: enhance sharing functionality by renaming state 2026-05-04 11:24:05 +02:00
MaxwellandLinus Rath 8b164c556e fix: thread per-account cookie slot through OAuth flows
The multi-account refresh-token cookie slot wiring was half-implemented:
every account's refresh token ended up on slot 0, so "+ Add Account"
silently clobbered the previous account's `jmap_rt` cookie. On page
refresh, only the most-recently-added account had a working refresh
token; the others bounced to login.

Three coordinated changes:

1. `app/[locale]/login/page.tsx` (handleOAuthLogin): write the next-free
   cookie slot to `sessionStorage['oauth_cookie_slot']` before redirecting
   to the IdP. `loginWithOAuth` already reads this key but it was never
   written, so it always defaulted to 0.

2. `stores/auth-store.ts` (loginWithOAuth): distinguish "no value set"
   (`rawSlot === null`) from "value is 0". Previously
   `parseInt(getItem(...) || '0')` collapsed both cases, making the
   `getNextCookieSlot()` fallback unreachable.

3. `stores/auth-store.ts` (loginWithServerSso) +
   `app/api/auth/sso/complete/route.ts`: pass the slot through the body of
   the POST and use it for `refreshTokenCookieName(slot)`. Same pattern as
   the existing `/api/auth/token POST` that already accepts a slot. The
   server defaults to 0 for back-compat with any caller that omits it.

After the fix, signing in with multiple accounts produces distinct
`jmap_rt`, `jmap_rt_1`, `jmap_rt_2`, ... cookies (matching the cookieSlot
field in account-store) and all accounts survive a page refresh.

Repro before the fix:
- Sign in with one account, refresh — works.
- Click "+ Add Account", sign in with a second account, refresh — second
  account vanishes from the dropdown; switching to the first account in
  the dropdown still shows the second account's identity in the From box.
2026-05-04 11:22:45 +02:00
Linus Rath 2e1f53c899 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-03 20:07:20 +02:00
Linus Rath a6d2efaf74 feat: sanitize identity display name to prevent invalid From headers 2026-05-03 20:06:41 +02:00
Luis Felipe MarzagaoandLinus Rath 01cd9644ed i18n: update mailbox context menu across 12 locales 2026-05-03 11:01:39 +02:00
Linus Rath 1521826d37 feat: add functionality to automatically add recipients to trusted senders when replying 2026-05-02 23:50:50 +02:00
Linus Rath 0d218d0d2a fix: square the colored left marker on calendar events 2026-05-02 23:41:08 +02:00
Linus Rath 9777dd655c feat: add share indicators for calendars and contacts, update JMAP capabilities #244 2026-05-02 23:29:21 +02:00
Linus Rath f970fd1822 feat: add plugin hooks for compose, attachments, search, lifecycle, and routing 2026-05-02 21:27:56 +02:00
Linus Rath 5e096240b3 feat: refresh update status on every dev reload 2026-05-02 13:23:58 +02:00
Linus Rath bc97a1ac10 feat: make update notice non-dismissible 2026-05-02 13:07:34 +02:00
Linus Rath 4594fb2572 revert: restore VERSION to correct value 2026-05-02 01:59:27 +02:00
Linus Rath 5319562c94 feat: add update-available detection 2026-05-02 01:58:30 +02:00
Linus Rath 599fa66822 fix: show git commit in About instead of "unknown" 2026-05-02 00:28:08 +02:00
Linus Rath 8041700668 chore: update version to 1.6.0 2026-05-01 22:02:57 +02:00
Linus Rath bade68a8b8 i18n: add missing email viewer detail and authentication translations 2026-05-01 22:02:14 +02:00
Linus Rath 4be7176802 chore: update version to 1.6.0 2026-05-01 21:56:53 +02:00
Linus Rath 8813533958 fix: respect per-email dark mode toggle when always-light setting is on 2026-05-01 21:42:02 +02:00
Linus Rath affa239d75 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-01 21:35:53 +02:00
Linus Rath 5d292fa43f fix: scroll apps list in navigation rail to prevent overflow 2026-05-01 21:35:29 +02:00
Linus Rath 878df6bb49 refactor: enhance path rendering in mailbox context menu 2026-05-01 21:26:03 +02:00
Linus Rath 607a9584fd refactor: implement path shortening for mailbox context menu 2026-05-01 21:25:31 +02:00
Linus Rath b8e2bfd793 fix: show full path in mailbox context menu header 2026-05-01 21:15:47 +02:00
Linus Rath 4ad6d37877 fix: clamp context submenu inside viewport 2026-05-01 21:03:54 +02:00
Linus Rath d7c29b7bec refactor: rework mobile mail viewer toolbar 2026-05-01 21:00:34 +02:00
Luis Felipe MarzagaoandLinus Rath b86bc541ab i18n: add missing keys accross 14 locales 2026-05-01 20:37:51 +02:00
Luis Felipe MarzagaoandLinus Rath 8c74e01a40 fix: add useTranslations for "selected emails" and "cancel" on email list batch operations 2026-05-01 20:37:51 +02:00
Linus Rath 231a9017d2 refactor: make settings panel mobile friendly 2026-05-01 20:30:15 +02:00
Linus Rath 9a5bb78b18 refactor: make admin panel mobile friendly 2026-05-01 20:22:29 +02:00
Linus Rath eecf16daa2 fix: stop silently destroying emails when trash mailbox isnt found #195 2026-05-01 20:01:13 +02:00
Vadim BelovandLinus Rath 210150a02e Fix push preview JMAP query
Resolve the Inbox mailbox id before running Email/query.

The previous query passed a JMAP result reference object directly into the inMailbox filter, which can make the preview endpoint return 502 and cause push notifications to fall back to the generic “New mail” text.
2026-05-01 19:53:25 +02:00
Linus Rath e50691d6c4 fix: navigate tour to mailbox when starting from another page 2026-05-01 18:30:51 +02:00
Linus Rath 089963b1b3 refactor: redesign expanded details panel 2026-05-01 18:22:06 +02:00
Linus Rath 4af952613a fix: prevent context menu jump and animation on open 2026-05-01 17:43:07 +02:00
Linus Rath 0d9fa0285f fix: prevent context menu from clipping below viewport 2026-05-01 17:28:40 +02:00
Linus Rath 683fe75864 i18n: translate SPF/DKIM/DMARC tooltips 2026-05-01 17:25:52 +02:00
Linus Rath e9c9be84ad fix: preserve list scroll position when tagging an email 2026-05-01 17:18:11 +02:00
Linus Rath 7822a363dd fix: render below-header overflow popup outside clipped row 2026-05-01 17:10:17 +02:00
Linus Rath 1e535e96a2 feat: image attachment thumbnails and preview chips 2026-05-01 17:03:45 +02:00
Linus Rath 32135ddb95 fix: collapse below-header attachments to single row with overflow pill 2026-05-01 16:29:38 +02:00
Linus Rath 841513e510 feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH environment variable 2026-05-01 14:57:49 +02:00
Linus RathandGitHub 5964b2e456 Update README 2026-05-01 10:06:49 +02:00
Linus Rath 6a8ad525f1 chore: update version to 1.5.4 2026-05-01 02:17:33 +02:00
Linus Rath 31d17098d6 feat: open mail from push notification clicks 2026-05-01 02:13:25 +02:00
Linus Rath 3f97e6ed8d fix: scope email notifications to genuine inbox deliveries 2026-05-01 02:08:13 +02:00
Linus Rath 2dea33e698 feat: improve new email push notification logic for inbox 2026-05-01 01:57:57 +02:00
Linus Rath 123764f8b8 feat: improve new email notification logic for inbox 2026-05-01 01:55:24 +02:00
Linus Rath ec0f355c13 feat: allow custom sub-addressing delimiter character #239 2026-05-01 01:48:39 +02:00
Linus Rath c555973b6b feat: configurable sub-addressing delimiter #239 2026-05-01 01:42:06 +02:00
Linus Rath a8db02e881 i18n: add missing keys across 15 locales 2026-05-01 01:26:22 +02:00
AbdullahandLinus Rath 7dc5984359 feat(i18n): add Turkish localization 2026-05-01 01:16:30 +02:00
Linus Rath 1c3003421e fix: extend timeout for PushVerification and clean up leftover subscriptions 2026-05-01 00:54:40 +02:00
Linus Rath f3d9115ecd feat: web push notifications for PWA #233 2026-05-01 00:26:48 +02:00
Linus Rath 4400a7abba fix: evict unrecoverable basic-auth accounts on reload 2026-04-30 15:39:11 +02:00
Linus Rath 45a4db1c22 fix: pin JMAP auth verification to configured server URL #237 2026-04-30 15:34:14 +02:00
Linus Rath 65eef4b2b8 fix: persist htmlBody in drafts to preserve rich formatting #236 2026-04-30 15:24:09 +02:00
Linus Rath 25de7d996c feat: add tables to composer #236 2026-04-30 15:16:51 +02:00
Luis Felipe MarzagaoandLinus Rath c406fbb73e fixup! fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath 31024396e3 fixup! fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath f0967f90eb fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath a4bb8e0c28 fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Linus Rath 7188abc9bc fix: set In-Reply-To and References on replies #234 2026-04-30 01:25:04 +02:00
Linus Rath 4a91cd0c44 style: drop iframe border-radius 2026-04-29 20:26:40 +02:00
Linus Rath 6abf8a5dd8 fix: detect <style> tag for padding 2026-04-29 20:25:24 +02:00
Linus Rath 3667c842c6 fix: light-mode override to body content only 2026-04-29 20:17:59 +02:00
Linus Rath b64721b43c fix: pad bare HTML emails like plain-text mails 2026-04-29 20:09:00 +02:00
Linus Rath 6b5ca2cb89 fix: smooth out body load, prevent flicker 2026-04-29 20:03:58 +02:00
Linus Rath 0f6e4f995f fix: prevent iframe flash on Load images or Trust sender 2026-04-29 18:29:48 +02:00
Linus Rath 0b6fdcabfb fix: update connect-src to include http protocol in development mode 2026-04-29 13:55:38 +02:00
Linus Rath fc49fe0687 chore: update version to 1.5.3 2026-04-28 18:43:09 +02:00
Linus Rath 419382d25d feat: add contacts feature gate and update telemetry payload 2026-04-28 17:54:11 +02:00
Linus Rath 8935b81f12 chore: update version to 1.5.3 2026-04-28 17:34:06 +02:00
Linus Rath ec581ce53e feat: update Docker configuration 2026-04-28 17:27:47 +02:00
Linus Rath 81d8465a79 fix: block telemetry endpoint from pointing at internal hosts 2026-04-28 17:16:30 +02:00
Linus Rath 0f3b506604 feat: add extension preview page and API for detailed extension information 2026-04-28 15:55:23 +02:00
Linus Rath 1b84547211 feat: add Theme API v2 with token compiler, skin slot 2026-04-28 15:39:13 +02:00
Linus Rath dafc8ace3c feat: track unique logins 2026-04-28 08:19:46 +02:00
Linus Rath 2c419cc4fe feat: add right-click context menu on empty calendar space 2026-04-28 01:56:15 +02:00
Linus Rath 90acf181f3 fix: harden plugin config, TOTP token exchange, and branding file serving 2026-04-28 01:44:37 +02:00
Linus Rath 54af07f2af feat: add anonymous instance telemetry
Adds a once-per-day heartbeat that lets the project see how many
instances run Bulwark, on what platforms, with what features enabled,
and roughly how many accounts they have. No email addresses, hostnames,
IPs, or any end-user data are ever sent.

- lib/telemetry: state file, payload builder, jittered scheduler,
  instance_id persistence at <data-dir>/.telemetry-id (delete to reset)
- app/api/admin/telemetry: admin API for status / set-consent /
  set-endpoint / send-now (all audit-logged)
- app/admin/telemetry: settings page with status, JSON payload preview,
  endpoint editor, send-now button, link to the privacy page
- instrumentation.node.ts: starts the scheduler on boot

Default state is enabled. The first heartbeat fires 1 hour after boot
so an admin who installs and immediately disables produces zero pings.
Disable via the settings UI, BULWARK_TELEMETRY=off (or
BULWARK_TELEMETRY_DISABLED=1), or by clearing the endpoint.

Account counts are bucketed (1, 2-5, 6-10, 11-50, 51-200, 201+) so a
small instance can't be re-identified by exact size. The /.telemetry-id
file can be deleted to mint a fresh instance_id.

Receiving collector is open source at bulwarkmail/dashboard. Self-host
your own and point at it via BULWARK_TELEMETRY_URL. Full schema,
retention (90d raw → aggregates), and lawful basis are documented at
bulwarkmail.org/docs/legal/privacy/telemetry.
2026-04-28 01:28:41 +02:00
Linus Rath 68f1fabc4b fix: batch shortcuts act on multi-selection when present #228 2026-04-28 00:04:29 +02:00
Linus Rath 27451807db chore: update version to 1.5.2 2026-04-27 22:45:23 +02:00
Linus Rath 55099bdcbb fix: disable Stalwart admin check when custom JMAP endpoints allowed 2026-04-27 22:41:51 +02:00
Linus Rath 71eb720065 chore: update version to 1.5.2 2026-04-27 22:30:32 +02:00
Linus Rath 8abb0c8717 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 22:24:59 +02:00
Linus Rath 3043639d2d fix: validate URLs before outbound fetch 2026-04-27 22:23:39 +02:00
Linus Rath e9b3eacbb7 fix: prevent drag creation on touch events in useTimeGridInteractions 2026-04-27 13:37:34 +02:00
Linus Rath f37e55e285 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 10:36:22 +02:00
c0af2dbdd1 i18n: Fix word 'ficheiro' and its variants.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:17:32 +02:00
Luis Felipe MarzagaoandLinus Rath ae517732f7 i18n: Fix word 'contactos' and its variants. 2026-04-27 10:17:32 +02:00
Linus Rath 3e1de10213 feat: add JMAP sharing for calendars and address books 2026-04-26 20:10:04 +02:00
Roman VanicekandLinus Rath 511740bb6d Additional Czech translations for recent changes. 2026-04-26 15:48:43 +02:00
Roman VanicekandLinus Rath 4a24d2a11d feat: add Czech language support 2026-04-26 15:48:43 +02:00
Linus Rath aadf56c27b fix: emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import #224 #187 2026-04-26 02:47:19 +02:00
Linus Rath 9f8588eadc fix: hide preview line in compact density to match settings preview (#223) 2026-04-26 02:12:28 +02:00
Linus Rath d657aec391 feat: add support for right-side composer sidebar and update related types 2026-04-25 19:45:45 +02:00
Linus RathandCopilot e683c90404 fix: implement inline matcher for Next.js proxy and remove unnecessary config
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:25:15 +02:00
Linus Rath cfb4a23c9d fix: remove unnecessary runtime config for Node.js in proxy settings 2026-04-25 18:54:15 +02:00
Linus Rath fe1d4861bb feat: composer-sidebar slot + plugin-declared frame-src origins 2026-04-25 18:40:54 +02:00
Linus Rath 5aa9b1d5f9 fix: honor SESSION_SECRET_FILE in dashboard warning check #222 2026-04-25 17:21:17 +02:00
Linus Rath 0913dbd3e4 chore: update version to 1.5.1 2026-04-25 16:34:45 +02:00
Linus Rath 29197ea355 feat: replace folder prompt() calls with proper modal dialog 2026-04-25 16:30:18 +02:00
Linus Rath 4788e8a91a feat: add right-click context menu to mail folders sidebar 2026-04-25 16:26:05 +02:00
Linus Rath b80678b00f feat: add 'Today' button to desktop calendar toolbar 2026-04-25 03:11:16 +02:00
Linus Rath 4f7c9c332b feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs 2026-04-25 02:53:12 +02:00
Linus Rath da103ff06f feat: implement OAuth auto-setup functionality for Stalwart integration 2026-04-25 01:37:18 +02:00
Linus Rath 2111c77870 fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:13:17 +02:00
Linus Rath e5083ec1df fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:12:33 +02:00
Linus Rath df8d04e233 fix: restore broken viewer toolbar actions and improve mobile menu #220 2026-04-24 19:31:38 +02:00
Linus RathandGitHub 9a11a18a44 Update README.md 2026-04-24 17:02:00 +02:00
Christian Lepthien ce9f7af330 fix: improve corean flag 2026-04-24 14:56:58 +02:00
Linus Rath 081e8a0310 feat: add setting to show avatars in junk folder, disabled by default 2026-04-23 18:23:48 +02:00
Linus Rath 6c3529b368 fix: stop flicker on background folder refresh 2026-04-23 18:06:40 +02:00
Linus Rath 077a4f03a7 fix: preserve search/filter on batch move and archive 2026-04-23 18:01:21 +02:00
Linus Rath b04dfaf252 fix: preserve search/filter when moving emails via drag-drop 2026-04-23 16:37:17 +02:00
Linus Rath 9c7452e7fd chore: add admin dashboard data directory configuration to environment files 2026-04-23 11:22:14 +02:00
176 changed files with 20413 additions and 1798 deletions
+30 -4
View File
@@ -71,11 +71,36 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Directory for storing encrypted settings files (default: ./data/settings). # Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, the working directory is /app, so the default resolves to # For Docker, the working directory is /app, so the default resolves to
# /app/data/settings - mount a persistent volume there: # /app/data/settings - mount a persistent volume there (see docker-compose.yml).
# volumes:
# - bulwark-settings:/app/data/settings
# SETTINGS_DATA_DIR=./data/settings # SETTINGS_DATA_DIR=./data/settings
# =============================================================================
# Admin Dashboard Data
# =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash,
# installed plugins/themes, and audit logs (default: ./data/admin).
# For Docker, the default resolves to /app/data/admin - mount a persistent
# volume there (see docker-compose.yml).
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
# so the instance id and consent choice survive upgrades.
# TELEMETRY_DATA_DIR=./data/telemetry
# ============================================================================= # =============================================================================
# Server Listen Address # Server Listen Address
# ============================================================================= # =============================================================================
@@ -183,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# ============================================================================= # =============================================================================
# URL of the BulwarkMail extension directory for the admin marketplace. # URL of the BulwarkMail extension directory for the admin marketplace.
# Set this to enable browsing and installing plugins/themes from the directory. # Defaults to https://extensions.bulwarkmail.org. Override only if you run
# 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
# ============================================================================= # =============================================================================
@@ -50,6 +50,8 @@ jobs:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+2
View File
@@ -78,6 +78,8 @@ jobs:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+138
View File
@@ -1,5 +1,143 @@
# Changelog # Changelog
## 1.6.1 (2026-05-04)
### Features
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
- **Mail**: Auto-add recipients to trusted senders when replying
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
### Fixes
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
- **Viewer**: Update email viewer styles to improve overflow handling
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
- **Auth**: Thread per-account cookie slot through OAuth flows
- **Calendar**: Square the colored left marker on calendar events
- **About**: Show git commit in About instead of "unknown"
### i18n
- Update mailbox context menu translations across 12 locales
## 1.6.0 (2026-05-01)
### Features
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
- **Mail**: Image attachment thumbnails and preview chips
- **Mobile**: Reworked mobile mail viewer toolbar
- **Mobile**: Mobile-friendly settings panel
- **Mobile**: Mobile-friendly admin panel
- **Mail**: Redesigned expanded details panel
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
### Fixes
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
- **Context menu**: Clamp submenu inside viewport
- **Context menu**: Prevent context menu from clipping below viewport
- **Context menu**: Prevent jump and animation on open
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
- **Mail**: Preserve list scroll position when tagging an email
- **Mail**: Render below-header overflow popup outside clipped row
- **Mail**: Collapse below-header attachments to single row with overflow pill
- **Push**: Fix push preview JMAP query
- **Tour**: Navigate tour to mailbox when starting from another page
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
### i18n
- Translate SPF/DKIM/DMARC tooltips
- Add missing keys across 14 locales
## 1.5.4 (2026-05-01)
### Features
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
- **Composer**: Insert and edit tables in rich-text emails (#236)
- **Mail**: Configurable sub-addressing delimiter character (#239)
- **i18n**: Turkish localization
- **i18n**: Missing keys filled in across 15 locales
### Fixes
- **Mail**: Set In-Reply-To and References headers on replies (#234)
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
- **Auth**: Evict unrecoverable basic-auth accounts on reload
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
- **Viewer**: Smooth out body load to prevent flicker on first render
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
- **Viewer**: Light-mode override now only affects body content
- **Viewer**: Detect `<style>` tag when applying padding
- **Viewer**: Drop iframe border-radius
- **Calendar**: Localize event start date in detail popover and event modal
- **Dev**: Include http protocol in connect-src for development mode CSP
## 1.5.3 (2026-04-28)
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
### Features
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
- **Plugins**: Theme API v2 with token compiler and skin slot
- **Plugins**: Extension preview page and detailed extension info API
- **Calendar**: Right-click context menu on empty calendar space
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
### Fixes
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
## 1.5.2 (2026-04-27)
### Features
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Calendar/Contacts**: JMAP sharing for calendars and address books
- **i18n**: Czech language support
### Fixes
- **Security**: Validate URLs before outbound fetch
- **Calendar**: Prevent drag creation on touch events in the time grid
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
- **Mail**: Hide preview line in compact density to match settings preview (#223)
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
## 1.5.1 (2026-04-25)
### Features
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
- **Mail**: Right-click context menu on the folders sidebar
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
- **Junk**: Setting to show avatars in the Junk folder (off by default)
### Fixes
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
- **Folders**: Stop flicker on background folder refresh
- **Email**: Preserve search/filter on batch move and archive
- **Email**: Preserve search/filter when moving emails via drag-drop
- **i18n**: Improve Korean flag
## 1.5.0 (2026-04-22) ## 1.5.0 (2026-04-22)
### Breaking Changes ### Breaking Changes
+9 -1
View File
@@ -4,6 +4,14 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it — CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack RUN npx next build --webpack
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
@@ -26,7 +34,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+1 -1
View File
@@ -94,7 +94,7 @@
## Internationalization ## Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська 15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
+23 -3
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -55,14 +55,14 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
## Overview ## Overview
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, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates - **Mail** threading, unified inbox, 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: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard. Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**. Full feature list: **[FEATURES.md](FEATURES.md)**.
@@ -217,6 +217,26 @@ LOG_LEVEL=info # error | warn | info | debug
</details> </details>
<details>
<summary>Subpath / reverse proxy mount</summary>
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
```env
NEXT_PUBLIC_BASE_PATH=/webmail
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
```
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
```bash
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.
</details>
## Keyboard Shortcuts ## Keyboard Shortcuts
| Key | Action | | Key | Action |
+1 -1
View File
@@ -1 +1 @@
1.5.0 1.6.1
+135 -5
View File
@@ -31,6 +31,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu"; import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export"; import { downloadEventICS } from "@/lib/calendar-ics-export";
@@ -47,7 +48,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
@@ -72,7 +77,8 @@ export default function CalendarPage() {
calendars, events, selectedDate, viewMode, selectedCalendarIds, calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error, isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
@@ -91,9 +97,15 @@ export default function CalendarPage() {
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null); const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
const tMgmt = useTranslations("calendar.management");
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null); const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>(); const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>(); const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
const [miniMonth, setMiniMonth] = useState(new Date()); const [miniMonth, setMiniMonth] = useState(new Date());
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null); const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null); const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
@@ -316,11 +328,12 @@ export default function CalendarPage() {
setSelectedDate(date); setSelectedDate(date);
}, [setSelectedDate]); }, [setSelectedDate]);
const openCreateModal = useCallback((date?: Date, endDate?: Date) => { const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
setEditEvent(null); setEditEvent(null);
const d = date || selectedDate; const d = date || selectedDate;
setDefaultModalDate(d); setDefaultModalDate(d);
setDefaultModalEndDate(endDate); setDefaultModalEndDate(endDate);
setDefaultModalAllDay(allDay ?? false);
setSelectedDate(d); setSelectedDate(d);
setShowEventModal(true); setShowEventModal(true);
}, [selectedDate, setSelectedDate]); }, [selectedDate, setSelectedDate]);
@@ -385,6 +398,21 @@ export default function CalendarPage() {
openEventContextMenu(e, event); openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]); }, [closeDetail, openEventContextMenu]);
const {
contextMenu: emptyContextMenu,
openContextMenu: openEmptyContextMenu,
closeContextMenu: closeEmptyContextMenu,
menuRef: emptyContextMenuRef,
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
const handleContextMenuEmpty = useCallback(
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
closeDetail();
openEmptyContextMenu(e, { date, hour, allDayArea });
},
[closeDetail, openEmptyContextMenu],
);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return; if (isMobile) return;
if (calendarHoverPreview === 'off') return; if (calendarHoverPreview === 'off') return;
@@ -951,6 +979,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent} onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile} isMobile={isMobile}
@@ -968,6 +997,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent} onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat} timeFormat={timeFormat}
@@ -987,6 +1017,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent} onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
@@ -1102,6 +1133,42 @@ export default function CalendarPage() {
} }
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
onCreateEvent={(cal: Calendar) => {
setDefaultCalendarIdForCreate(cal.id);
openCreateModal();
}}
onClearCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("clear_events"),
message: tMgmt("confirm_clear", { name: cal.name }),
variant: "destructive",
confirmText: tMgmt("clear_events"),
});
if (!ok) return;
try {
const count = await clearCalendarEvents(client, cal.id);
toast.success(tMgmt("events_cleared", { count }));
} catch {
toast.error(tMgmt("error_clear"));
}
} : undefined}
onDeleteCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("delete"),
message: tMgmt("confirm_delete", { name: cal.name }),
variant: "destructive",
confirmText: tMgmt("delete"),
});
if (!ok) return;
try {
await removeCalendar(client, cal.id);
toast.success(tMgmt("calendar_deleted"));
} catch {
toast.error(tMgmt("error_delete"));
}
} : undefined}
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)} onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
@@ -1165,11 +1232,13 @@ export default function CalendarPage() {
calendars={calendars} calendars={calendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent} onSave={handleSaveEvent}
onDelete={handleDeleteEvent} onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent} onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp} onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }} onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview} onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
isMobile={false} isMobile={false}
@@ -1235,6 +1304,38 @@ export default function CalendarPage() {
/> />
)} )}
{emptyContextMenu.data && (() => {
const { date, hour } = emptyContextMenu.data;
return (
<EmptySpaceContextMenu
position={emptyContextMenu.position}
isOpen={emptyContextMenu.isOpen}
onClose={closeEmptyContextMenu}
menuRef={emptyContextMenuRef}
onNewEvent={() => {
const d = new Date(date);
if (typeof hour === "number") {
d.setHours(hour, 0, 0, 0);
} else {
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
}
openCreateModal(d);
}}
onNewAllDayEvent={() => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
openCreateModal(d, undefined, true);
}}
onNewTask={enableCalendarTasks ? () => {
setEditTask(null);
setShowTaskModal(true);
} : undefined}
onGoToToday={goToToday}
/>
);
})()}
{detailEvent && detailAnchorRect && ( {detailEvent && detailAnchorRect && (
<EventDetailPopover <EventDetailPopover
event={detailEvent} event={detailEvent}
@@ -1261,11 +1362,13 @@ export default function CalendarPage() {
calendars={calendars} calendars={calendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent} onSave={handleSaveEvent}
onDelete={handleDeleteEvent} onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent} onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp} onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }} onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
isMobile={true} isMobile={true}
/> />
@@ -1305,6 +1408,33 @@ export default function CalendarPage() {
onSelect={handleScopeSelect} onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)} onClose={() => setPendingScopeAction(null)}
/> />
<ConfirmDialog {...confirmDialogProps} />
{showCreateCalendar && client && (
<CreateCalendarModal
client={client}
onClose={() => setShowCreateCalendar(false)}
/>
)}
{sharingCalendarId && client && (() => {
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingCalendarId(null)}
/>
);
})()}
</div> </div>
); );
} }
+46 -2
View File
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
type View = type View =
| "list" | "list"
@@ -75,6 +76,8 @@ export default function ContactsPage() {
bulkAddToGroup, bulkAddToGroup,
moveContactToAddressBook, moveContactToAddressBook,
renameAddressBook, renameAddressBook,
removeAddressBook,
shareAddressBook,
renameKeyword, renameKeyword,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
@@ -83,6 +86,8 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all"); const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null); const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null); const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
@@ -329,6 +334,7 @@ export default function ContactsPage() {
addLocalContact(localContact); addLocalContact(localContact);
toast.success(t("toast.created")); toast.success(t("toast.created"));
} }
setDefaultBookIdForCreate(undefined);
setView("list"); setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]); }, [supportsSync, client, createContact, addLocalContact, t]);
@@ -346,6 +352,7 @@ export default function ContactsPage() {
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => { const handleCancel = () => {
setDefaultBookIdForCreate(undefined);
if (view === "group-create" || view === "group-edit") { if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list"); setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") { } else if (view === "bulk-add-to-group") {
@@ -517,7 +524,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -690,6 +697,26 @@ export default function ContactsPage() {
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory} onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined} onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
onCreateContactInBook={(book) => {
setDefaultBookIdForCreate(book.id);
handleCreateNew();
}}
onDeleteAddressBook={client ? async (book) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
message: t("address_books.confirm_delete", { name: book.name }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
if (!ok) return;
try {
await removeAddressBook(client, book);
toast.success(t("address_books.deleted"));
} catch {
toast.error(t("address_books.delete_failed"));
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)} onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/> />
</div> </div>
@@ -838,6 +865,23 @@ export default function ContactsPage() {
</div> </div>
</div> </div>
)} )}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingAddressBookId(null)}
/>
);
})()}
</div> </div>
); );
} }
+56 -5
View File
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
@@ -16,6 +17,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -28,7 +30,12 @@ const THEME_OPTIONS = [
function VersionBadge() { function VersionBadge() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`; const banner = useUpdateStore(useShallow(selectBanner));
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startPolling(); }, [startPolling]);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => { navigator.clipboard.writeText(versionInfo).then(() => {
@@ -37,16 +44,49 @@ function VersionBadge() {
}); });
}; };
const isRed = banner?.variant === "red";
const triggerText = !banner
? `v${APP_VERSION}`
: banner.severity === "security"
? "Security update available"
: banner.severity === "deprecated"
? "Version no longer supported"
: "New version available";
const triggerColor = !banner
? "text-muted-foreground/40"
: isRed
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
const triggerClass = cn(
"peer text-center text-xs transition-colors",
triggerColor,
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
);
const trigger = banner?.url ? (
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
{triggerText}
</a>
) : (
<p className={triggerClass}>{triggerText}</p>
);
return ( return (
<div className="relative inline-flex justify-center"> <div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default"> {trigger}
v{APP_VERSION}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10"> <div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p> <p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p> <p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
{banner?.latest && (
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
)}
{banner?.advisory && (
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
)}
</div> </div>
<button <button
onClick={handleCopy} onClick={handleCopy}
@@ -411,7 +451,8 @@ export default function LoginPage() {
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier); const challenge = await generateCodeChallenge(verifier);
const state = generateState(); const state = generateState();
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_state", state);
@@ -420,6 +461,16 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_add_account_mode", "true"); sessionStorage.setItem("oauth_add_account_mode", "true");
} }
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
// loginWithOAuth reads this key but it was previously never written, so every
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
// Account").
const nextSlot = useAccountStore.getState().getNextCookieSlot();
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
const authUrl = new URL(oauthMetadata.authorization_endpoint); const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oauthClientId); authUrl.searchParams.set("client_id", oauthClientId);
+497 -38
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list"; import { EmailList } from "@/components/email/email-list";
@@ -14,6 +15,7 @@ import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
@@ -23,6 +25,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
@@ -35,6 +38,7 @@ import {
ComposerErrorFallback, ComposerErrorFallback,
} from "@/components/error"; } from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context"; import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
@@ -48,12 +52,33 @@ import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store"; import { usePluginStore } from "@/stores/plugin-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import type { EmailReadView } from "@/lib/plugin-types";
function emailToReadView(email: Email): EmailReadView {
return {
id: email.id,
threadId: email.threadId,
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
subject: email.subject || '',
receivedAt: email.receivedAt,
isRead: !!email.keywords?.['$seen'],
isFlagged: !!email.keywords?.['$flagged'],
hasAttachment: email.hasAttachment,
preview: email.preview || '',
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
};
}
export default function Home() { export default function Home() {
@@ -67,6 +92,7 @@ export default function Home() {
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0); const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false);
@@ -110,6 +136,88 @@ export default function Home() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [isRateLimited, rateLimitUntil]); }, [isRateLimited, rateLimitUntil]);
// Plugin hooks: window-level lifecycle + selection + service-worker messages.
// One effect because the listeners share a registration / cleanup window.
useEffect(() => {
if (typeof window === 'undefined') return;
const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); };
const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); };
const onOnline = () => { appLifecycleHooks.onOnline.emit(); };
const onOffline = () => { appLifecycleHooks.onOffline.emit(); };
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
const onSelectionChange = () => {
if (selectionTimer) clearTimeout(selectionTimer);
selectionTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString() ?? '';
if (!text) return;
const anchorNode = sel?.anchorNode as Node | null;
const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE
? anchorNode as Element
: anchorNode?.parentElement) ?? null;
let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other';
let emailId: string | undefined;
if (anchorEl) {
if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) {
source = 'email-body';
const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null;
emailId = idEl?.dataset.emailId;
} else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) {
source = 'composer';
} else if (anchorEl.closest('[data-plugin-source="task-detail"]')) {
source = 'task-detail';
} else if (anchorEl.closest('[data-plugin-source="event-detail"]')) {
source = 'event-detail';
}
}
uiHooks.onTextSelectionChange.emit({ text, source, emailId });
}, 150);
};
const onSwMessage = (e: MessageEvent) => {
const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null;
if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') {
toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data });
}
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
document.addEventListener('selectionchange', onSelectionChange);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', onSwMessage);
}
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
document.removeEventListener('selectionchange', onSelectionChange);
if (selectionTimer) clearTimeout(selectionTimer);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.removeEventListener('message', onSwMessage);
}
};
}, []);
// Plugin hooks: route navigation. Tracks Next.js pathname transitions.
const pathname = usePathname();
const prevPathnameRef = useRef<string | null>(null);
useEffect(() => {
if (!pathname) return;
const from = prevPathnameRef.current;
if (from === pathname) return;
if (from !== null) {
routerHooks.onRouteLeave.emit({ path: from });
routerHooks.onNavigate.emit({ path: pathname, from });
}
routerHooks.onRouteEnter.emit({ path: pathname });
prevPathnameRef.current = pathname;
}, [pathname]);
// Mobile/tablet responsive hooks // Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection(); const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
@@ -134,6 +242,7 @@ export default function Home() {
deleteEmail, deleteEmail,
markAsRead, markAsRead,
toggleStar, toggleStar,
setEmailKeywordsLocal,
moveToMailbox, moveToMailbox,
moveThreadToMailbox, moveThreadToMailbox,
searchEmails, searchEmails,
@@ -162,6 +271,16 @@ export default function Home() {
fetchUnifiedEmails: fetchUnifiedEmailsAction, fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts, refreshUnifiedCounts,
exitUnifiedView, exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
createMailbox,
renameMailbox,
deleteMailbox,
batchDelete,
batchArchive,
batchMarkAsRead,
batchMarkAsSpam,
batchUndoSpam,
} = useEmailStore(); } = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -344,27 +463,77 @@ export default function Home() {
onToggleStar: () => { onToggleStar: () => {
if (selectedEmail) handleToggleStar(); if (selectedEmail) handleToggleStar();
}, },
onArchive: () => { onArchive: async () => {
if (selectedEmail) handleArchive(); if (selectedEmailIds.size > 0 && client) {
try {
await batchArchive(client);
} catch (error) {
console.error("Failed to batch archive:", error);
}
} else if (selectedEmail) {
handleArchive();
}
}, },
onDelete: () => { onDelete: async () => {
if (selectedEmail) handleDelete(); if (selectedEmailIds.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk);
const confirmed = await confirmDialog({
title: permanent
? t('email_list.permanent_delete_confirm_title')
: t('email_list.batch_actions.delete_confirm_title'),
message: permanent
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
confirmText: permanent
? t('email_list.permanent_delete')
: t('email_list.batch_actions.delete'),
variant: "destructive",
});
if (!confirmed) return;
try {
await batchDelete(client, permanent);
} catch (error) {
console.error("Failed to batch delete:", error);
}
} else if (selectedEmail) {
handleDelete();
}
}, },
onMarkAsUnread: async () => { onMarkAsUnread: async () => {
if (selectedEmail && client) { if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, false);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false); await markAsRead(client, selectedEmail.id, false);
} }
}, },
onMarkAsRead: async () => { onMarkAsRead: async () => {
if (selectedEmail && client) { if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, true);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true); await markAsRead(client, selectedEmail.id, true);
} }
}, },
onToggleSpam: () => { onToggleSpam: async () => {
if (selectedEmail) { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
// Check if we're in junk folder const isInJunk = currentMailbox?.role === 'junk';
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (selectedEmailIds.size > 0 && client) {
const isInJunk = currentMailbox?.role === 'junk'; const ids = Array.from(selectedEmailIds);
try {
if (isInJunk) {
await batchUndoSpam(client, ids);
} else {
await batchMarkAsSpam(client, ids);
}
} catch (error) {
console.error("Failed to batch toggle spam:", error);
}
} else if (selectedEmail) {
if (isInJunk) { if (isInJunk) {
handleUndoSpam(); handleUndoSpam();
} else { } else {
@@ -399,13 +568,14 @@ export default function Home() {
clearSelection(); clearSelection();
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]); }), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
// Initialize keyboard shortcuts // Initialize keyboard shortcuts
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer, enabled: isAuthenticated && !showComposer,
emails, emails,
selectedEmailId: selectedEmail?.id, selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
handlers: keyboardHandlers, handlers: keyboardHandlers,
}); });
@@ -586,11 +756,50 @@ export default function Home() {
}); });
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); }, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
// preview API failed). We resolve those params once after the inbox has
// finished loading and open the right message, then strip the params so a
// refresh doesn't re-open it.
const notificationParamHandledRef = useRef(false);
useEffect(() => {
if (notificationParamHandledRef.current) return;
if (!isAuthenticated || !client) return;
if (mailboxes.length === 0) return;
const params = new URLSearchParams(window.location.search);
const emailIdParam = params.get('email');
const openLatestUnread = params.get('openLatestUnread') === '1';
if (!emailIdParam && !openLatestUnread) return;
// For the latest-unread case we need the inbox emails loaded; bail and
// let the effect re-run once `emails` is populated.
if (openLatestUnread && emails.length === 0) return;
notificationParamHandledRef.current = true;
window.history.replaceState({}, '', window.location.pathname);
if (emailIdParam) {
setLoadingEmail(true);
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
return;
}
// emails are sorted receivedAt-desc, so the first unread is the newest.
const newestUnread = emails.find(e => !e.keywords?.$seen);
if (newestUnread) {
selectEmail(newestUnread);
}
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => { useEffect(() => {
if (!selectedEmail || !client) return; if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content // If the email lacks bodyValues, it was auto-selected from the list and needs full content.
if (!selectedEmail.bodyValues) { // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) ? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined; : undefined;
@@ -685,6 +894,8 @@ export default function Home() {
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => { }) => {
if (!client) return; if (!client) return;
@@ -692,7 +903,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; const originalEmailId = selectedEmail?.id;
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments); await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
setShowComposer(false); setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword // Mark the original email with $answered or $forwarded keyword
@@ -727,7 +938,15 @@ export default function Home() {
} }
}; };
const handleReply = (draftText?: string) => { const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply' as const,
});
if (!ok) return;
}
setComposerDraftText(draftText || ""); setComposerDraftText(draftText || "");
setComposerMode('reply'); setComposerMode('reply');
setShowComposer(true); setShowComposer(true);
@@ -786,13 +1005,29 @@ export default function Home() {
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
const handleReplyAll = () => { const handleReplyAll = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply-all' as const,
});
if (!ok) return;
}
setComposerMode('replyAll'); setComposerMode('replyAll');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
const handleForward = () => { const handleForward = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeForward.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'forward' as const,
});
if (!ok) return;
}
setComposerMode('forward'); setComposerMode('forward');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
@@ -824,13 +1059,24 @@ export default function Home() {
} }
} else { } else {
// Not in trash: always move to trash // Not in trash: always move to trash
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared); const trashMailbox =
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
mailboxes.find(m => {
if (m.isShared) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
if (trashMailbox) { if (trashMailbox) {
try { try {
await moveToMailbox(client, emailToDelete.id, trashMailbox.id); await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) { } catch (error) {
console.error("Failed to move email to trash:", error); console.error("Failed to move email to trash:", error);
const { toast } = await import('sonner');
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
} }
} else {
const { toast } = await import('sonner');
toast.error('Trash mailbox not found - cannot move email to trash');
} }
} }
}; };
@@ -978,11 +1224,9 @@ export default function Home() {
// Update email keywords via JMAP // Update email keywords via JMAP
await client.updateEmailKeywords(emailId, keywords); await client.updateEmailKeywords(emailId, keywords);
// Update local state // Patch the email in place so the list keeps its scroll/pagination state
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail); // instead of being reset to the first page by a full refetch.
setEmailKeywordsLocal(emailId, keywords);
// Refresh emails list to show color in list
await fetchEmails(client, selectedMailbox);
// Refresh tag counts // Refresh tag counts
fetchTagCounts(client); fetchTagCounts(client);
@@ -1097,6 +1341,197 @@ export default function Home() {
} }
}; };
const tCtxMenu = t;
const handleMarkFolderRead = async (mailboxId: string) => {
if (!client) return;
try {
const count = await markMailboxAsRead(client, mailboxId);
await fetchMailboxes(client);
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
if (count > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkFolderTreeRead = async (mailboxId: string) => {
if (!client) return;
const collectIds = (rootId: string): string[] => {
const ids: string[] = [rootId];
const stack = [rootId];
while (stack.length > 0) {
const current = stack.pop()!;
for (const mb of mailboxes) {
if (mb.parentId === current) {
ids.push(mb.id);
stack.push(mb.id);
}
}
}
return ids;
};
try {
const ids = collectIds(mailboxId);
let total = 0;
for (const id of ids) {
total += await markMailboxAsRead(client, id);
}
await fetchMailboxes(client);
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkAllFoldersRead = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
variant: "default",
});
if (!confirmed) return;
try {
const total = await client.markAllAsRead();
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('email_list.empty_folder.confirm_title'),
message: tCtxMenu('email_list.empty_folder.confirm_message'),
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
try {
await emptyMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
}
};
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name, parentId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleCreateFolderFromContextMenu = async () => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_folder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.rename'),
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
defaultValue: mailbox.name,
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
});
if (!name || name === mailbox.name) return;
try {
await renameMailbox(client, mailboxId, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
}
};
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
if (jmapType === 'mailboxHasChild') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
} else if (jmapType === 'mailboxHasEmail') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
} else {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
}
}
};
const handleRefreshMailboxes = async () => {
if (!client) return;
try {
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
} catch {
// silent
}
};
const handleLogout = logout; const handleLogout = logout;
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
@@ -1193,6 +1628,12 @@ export default function Home() {
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
});
// Send reply with just the body text // Send reply with just the body text
await sendEmail( await sendEmail(
client, client,
@@ -1204,7 +1645,11 @@ export default function Home() {
primaryIdentity?.id, primaryIdentity?.id,
primaryIdentity?.email, primaryIdentity?.email,
undefined, undefined,
primaryIdentity?.name || undefined primaryIdentity?.name || undefined,
undefined,
undefined,
threading?.inReplyTo,
threading?.references,
); );
// Mark the original email as answered // Mark the original email as answered
@@ -1246,7 +1691,13 @@ export default function Home() {
setShowComposer(false); setShowComposer(false);
} }
// Set loading state immediately (keep current email visible) // Show the list stub immediately so subject/sender render without
// waiting for the body fetch — avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true); setLoadingEmail(true);
// On mobile, switch to viewer // On mobile, switch to viewer
@@ -1263,7 +1714,6 @@ export default function Home() {
try { try {
// In unified view each email carries its own accountId. Use that // In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it. // account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId) ? useAuthStore.getState().getClientForAccount(emailAccountId)
@@ -1295,15 +1745,11 @@ export default function Home() {
}; };
// Handle back navigation from viewer on mobile. // Handle back navigation from viewer on mobile.
// Delegate to the browser history stack so this button is equivalent to // Reset to list state directly. We can't just call window.history.back()
// the OS back button / mouse back button - popstate then restores the // because the nav hook pushes a new entry for every email the user opens,
// previous snapshot via handleNavRestore. The viewer is only reachable // so history.back() would pop to the previous email rather than the list.
// from a state that pushed history, so back() always lands on an app entry. // The OS / hardware back button is still wired through popstate → handleNavRestore.
const handleMobileBack = () => { const handleMobileBack = () => {
if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) { if (conversationThread) {
setConversationThread(null); setConversationThread(null);
setConversationEmails([]); setConversationEmails([]);
@@ -1458,6 +1904,15 @@ export default function Home() {
onMailboxSelect={handleMailboxSelect} onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect} onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick} onUnreadFilterClick={handleUnreadFilterClick}
onMarkFolderRead={handleMarkFolderRead}
onMarkFolderTreeRead={handleMarkFolderTreeRead}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={handleEmptyFolderFromContextMenu}
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => { onCompose={() => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
@@ -1850,6 +2305,9 @@ export default function Home() {
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt, receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments, attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
initialData={pendingDraft} initialData={pendingDraft}
@@ -1934,12 +2392,12 @@ export default function Home() {
onReply={handleReply} onReply={handleReply}
onReplyAll={handleReplyAll} onReplyAll={handleReplyAll}
onForward={handleForward} onForward={handleForward}
onDelete={handleDelete} onDelete={() => handleDelete()}
onArchive={() => handleArchive()} onArchive={() => handleArchive()}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam} onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={handleUndoSpam} onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => { onMarkAsRead={async (emailId, read) => {
if (client) { if (client) {
await markAsRead(client, emailId, read); await markAsRead(client, emailId, read);
@@ -2009,6 +2467,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} />
<TotpReauthDialog /> <TotpReauthDialog />
</div> </div>
</DragDropProvider> </DragDropProvider>
+20 -15
View File
@@ -213,6 +213,22 @@ export default function SettingsPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Sync the mobile submenu view with browser history so the system back
// button (or gesture) returns to the settings list before exiting /settings.
useEffect(() => {
if (isDesktop) return;
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
window.history.pushState({ __settingsSubmenu: true }, '');
const handlePop = () => {
setMobileShowContent(false);
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [isDesktop, mobileShowContent]);
if (!isAuthenticated) { if (!isAuthenticated) {
return null; return null;
} }
@@ -321,7 +337,7 @@ export default function SettingsPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setMobileShowContent(false)} onClick={() => window.history.back()}
className="h-10 w-10" className="h-10 w-10"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
@@ -330,9 +346,7 @@ export default function SettingsPage() {
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4"> {renderTabContent()}
{renderTabContent()}
</div>
</div> </div>
<NavigationRail <NavigationRail
@@ -515,17 +529,8 @@ export default function SettingsPage() {
/> />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8"> <div className="max-w-3xl mx-auto px-6 py-6">
<div className="mb-6"> {renderTabContent()}
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
</div> </div>
</div> </div>
</> </>
+177 -12
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react'; import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
@@ -69,6 +69,58 @@ export default function AdminAuthPage() {
} }
} }
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0; const hasEdits = Object.keys(edits).length > 0;
if (loading) { if (loading) {
@@ -77,8 +129,8 @@ export default function AdminAuthPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1> <h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p> <p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div> </div>
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
</div> </div>
)} )}
{/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
</p>
</div>
<button
onClick={openSetupDialog}
disabled={setupRunning}
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</button>
</div>
</div>
{/* Auto-setup dialog */}
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable OAuth only (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<button
onClick={() => setSetupOpen(false)}
disabled={setupRunning}
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</button>
</div>
</div>
</div>
)}
{/* OAuth */} {/* OAuth */}
<Section title="OAuth / OpenID Connect"> <Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
@@ -149,7 +314,7 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -157,11 +322,11 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder} <input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
{source === 'admin' && ( {source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button> <button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)} )}
</div> </div>
</div> </div>
@@ -173,7 +338,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -181,7 +346,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => onChange(configKey, !value)} <button onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
@@ -199,12 +364,12 @@ function Select({ label, configKey, value, source, options, onChange, onRevert }
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} <select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{o}</option>)} {options.map(o => <option key={o} value={o}>{o}</option>)}
+10 -10
View File
@@ -162,8 +162,8 @@ export default function AdminBrandingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Branding</h1> <h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p> <p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div> </div>
@@ -193,22 +193,22 @@ export default function AdminBrandingPage() {
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => ( {IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3"> <div key={field.key} className="px-4 py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label> <label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary"> <span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'} {isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={currentValue(field.key)} value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)} onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file" placeholder="Enter URL or upload a file"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
<input <input
ref={el => { fileInputRefs.current[field.key] = el; }} ref={el => { fileInputRefs.current[field.key] = el; }}
@@ -270,20 +270,20 @@ export default function AdminBrandingPage() {
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{TEXT_FIELDS.map(field => ( {TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label> <label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span> <span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={currentValue(field.key)} value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)} onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default"> <button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
+278 -106
View File
@@ -14,12 +14,16 @@ import {
KeyRound, KeyRound,
Puzzle, Puzzle,
SwatchBook, SwatchBook,
Activity,
Package,
Mail, Mail,
Calendar, Calendar,
BookUser, BookUser,
HardDrive, HardDrive,
ArrowLeft, ArrowLeft,
Store, Store,
Menu,
X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
@@ -27,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [ const NAV_GROUPS = [
@@ -56,6 +61,8 @@ const NAV_GROUPS = [
{ {
label: 'System', label: 'System',
items: [ items: [
{ href: '/admin/version', label: 'Version', icon: Package },
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, { href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
], ],
}, },
@@ -65,62 +72,89 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null); const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
useEffect(() => { useEffect(() => {
if (pathname !== '/admin/login') { setMobileNavOpen(false);
checkAuth();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]); }, [pathname]);
function getJmapHeaders(): Record<string, string> { useEffect(() => {
return getActiveAccountSlotHeaders(); if (!mobileNavOpen) return;
} const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [mobileNavOpen]);
async function checkAuth() { useEffect(() => {
try { if (pathname === '/admin/login') return;
const jmapHeaders = getJmapHeaders(); let cancelled = false;
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
const stalwartAdmin = data.stalwartAdmin === true; async function checkAuth() {
setIsStalwartAdmin(stalwartAdmin); try {
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
// If neither password-based admin nor Stalwart admin, redirect away const stalwartAdmin = data.stalwartAdmin === true;
if (!data.enabled && !stalwartAdmin) { setIsStalwartAdmin(stalwartAdmin);
router.replace('/');
return;
}
if (data.authenticated) { // If neither password-based admin nor Stalwart admin, redirect away
setAuthenticated(true); if (!data.enabled && !stalwartAdmin) {
return; router.replace('/');
} return;
}
// If Stalwart admin but not yet authenticated, auto-login if (data.authenticated) {
if (stalwartAdmin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (loginRes.ok) {
setAuthenticated(true); setAuthenticated(true);
return; return;
} }
}
router.replace('/admin/login'); // If Stalwart admin but not yet authenticated, auto-login
} catch { if (stalwartAdmin) {
router.replace('/admin/login'); const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (cancelled) return;
if (loginRes.ok) {
setAuthenticated(true);
return;
}
const body = await loginRes.json().catch(() => ({}));
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
setAuthenticated(false);
return;
}
router.replace('/admin/login');
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
} }
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router]);
async function handleLogout() { async function handleLogout() {
await apiFetch('/api/admin/auth', { method: 'DELETE' }); await apiFetch('/api/admin/auth', { method: 'DELETE' });
@@ -132,18 +166,90 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>; return <>{children}</>;
} }
if (authenticated === null) { const navContent = (
return ( <>
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="flex-1 overflow-y-auto py-2">
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div> <div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
const showDot = href === '/admin/version' && hasUpdate;
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</Link>
);
})}
</div>
))}
</div>
</div> </div>
);
} <div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
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"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</>
);
return ( return (
<div className="min-h-screen flex bg-background"> <div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */} {/* Slim webmail nav rail (desktop only) */}
<nav className="w-14 bg-secondary flex 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-r 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" />
) : ( ) : (
@@ -191,8 +297,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div> </div>
</nav> </nav>
{/* Admin Sidebar */} {/* Admin Sidebar (desktop only) */}
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen"> <aside className="hidden md:flex w-60 border-r 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 mr-2" />
@@ -201,77 +307,143 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)} )}
<span className="font-semibold text-sm text-foreground">Admin Panel</span> <span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div> </div>
{navContent}
</aside>
<div className="flex-1 overflow-y-auto py-2"> {/* Mobile drawer overlay */}
<div className="px-2 space-y-0.5"> {mobileNavOpen && (
{NAV_GROUPS.map((group, groupIndex) => ( <div
<div key={group.label}> className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />} onClick={() => setMobileNavOpen(false)}
<div className="px-3 pt-2.5 pb-1"> aria-hidden="true"
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> />
{group.label} )}
</span>
</div> {/* Mobile drawer */}
{group.items.map(({ href, label, icon: Icon }) => { <aside
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href); className={cn(
return ( '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',
<Link mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
key={href} )}
href={href} aria-label="Admin navigation"
className={cn( aria-hidden={!mobileNavOpen}
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', >
active <div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
? 'bg-accent text-accent-foreground font-medium' <div className="flex items-center min-w-0">
: 'hover:bg-muted text-foreground' {logoUrl ? (
)} <img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
> ) : (
<Icon className={cn( <Shield className="w-5 h-5 text-primary mr-2" />
'w-4 h-4 shrink-0', )}
active ? 'text-accent-foreground' : 'text-muted-foreground' <span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
)} />
{label}
</Link>
);
})}
</div>
))}
</div> </div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button <button
onClick={handleLogout} type="button"
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" onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
> >
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" /> <X className="w-5 h-5" />
Sign out
</button> </button>
</div> </div>
{navContent}
</aside> </aside>
{/* Main content */} {/* Main content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 min-w-0 overflow-x-hidden">
<div className="max-w-4xl mx-auto p-6"> {/* Mobile header */}
{children} <div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div> </div>
</main> </main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href="/"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href="/calendar"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href="/contacts"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
<a
href="/files"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href="/settings"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div> </div>
); );
} }
+36 -8
View File
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1> <h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p> <p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div> </div>
@@ -52,7 +52,7 @@ export default function AdminLogsPage() {
<select <select
value={actionFilter} value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }} onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All actions</option> <option value="">All actions</option>
<option value="admin.login">Login</option> <option value="admin.login">Login</option>
@@ -66,15 +66,43 @@ export default function AdminLogsPage() {
</select> </select>
</div> </div>
{/* Table */} {/* Mobile cards */}
<div className="border border-border rounded-lg overflow-hidden"> <div className="sm:hidden space-y-2">
{loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
{/* Desktop table */}
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<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">Time</th> <th className="text-left 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">Action</th> <th className="text-left 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-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th> <th className="text-left 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">
+544
View File
@@ -0,0 +1,544 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
Download,
Loader2,
Puzzle,
SwatchBook,
Star,
Trash2,
Check,
Settings as SettingsIcon,
ExternalLink,
Shield,
AlertTriangle,
FileCode,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface PreviewData {
extension: {
slug: string;
name: string;
type: 'plugin' | 'theme';
pluginType: string | null;
description: string;
longDescription: string | null;
tags: string[];
permissions: string[];
totalDownloads: number;
featured: boolean;
githubRepo: string | null;
license: string | null;
minAppVersion: string | null;
author: {
displayName: string;
githubLogin: string;
avatarUrl: string | null;
verified?: boolean;
} | null;
latestVersion: string | null;
versions: Array<{
version: string;
changelog: string | null;
bundleSize: number;
minAppVersion: string | null;
publishedAt: string | null;
permissions: string[];
}>;
screenshots: Array<{ url: string; altText: string | null }>;
themePreviews: Array<{
variant: 'light' | 'dark';
previewPath: string;
colors: Record<string, string> | null;
}>;
createdAt: string | null;
updatedAt: string | null;
};
bundle: {
manifest: Record<string, unknown> | null;
source: { name: string; content: string; truncated: boolean } | null;
size: number;
error: string | null;
};
installed: boolean;
}
const RISKY_PERMISSIONS = new Set([
'mail:write',
'mail:delete',
'storage:write',
'network',
'admin',
]);
export default function MarketplacePreviewPage() {
const params = useParams();
const slug = params.slug as string;
const [data, setData] = useState<PreviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showSource, setShowSource] = useState(false);
const [showManifest, setShowManifest] = useState(false);
const fetchPreview = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error || 'Failed to load preview');
return;
}
setData(await res.json());
} catch {
setError('Failed to connect to extension directory');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => { fetchPreview(); }, [fetchPreview]);
async function handleInstall() {
if (!data) return;
setInstalling(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0',
type: data.extension.type,
}),
});
const body = await res.json();
if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
setData(prev => prev ? { ...prev, installed: true } : prev);
} else {
setMessage({ type: 'error', text: body.error || 'Installation failed' });
}
} catch {
setMessage({ type: 'error', text: 'Installation failed - network error' });
} finally {
setInstalling(false);
}
}
async function handleUninstall() {
if (!data) return;
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
setUninstalling(true);
setMessage(null);
try {
const endpoint = data.extension.type === 'theme'
? '/api/admin/themes'
: '/api/admin/plugins';
const res = await apiFetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: data.extension.slug }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
setData(prev => prev ? { ...prev, installed: false } : prev);
} else {
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
}
} catch {
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
} finally {
setUninstalling(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading...
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
</div>
);
}
const ext = data.extension;
const bundle = data.bundle;
const isPlugin = ext.type === 'plugin';
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
return (
<div className="space-y-6 max-w-4xl">
{/* Back link */}
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? (
<>
<Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Manage
</Link>
<button
onClick={handleUninstall}
disabled={uninstalling}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Uninstall
</button>
</>
) : (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install
</button>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{bundle.error && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Could not preview bundle</p>
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
</div>
</div>
)}
{/* Description */}
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">About</h2>
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
{ext.longDescription && ext.longDescription !== ext.description && (
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
)}
{ext.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{ext.tags.map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
{ext.githubRepo && (
<a
href={`https://github.com/${ext.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ExternalLink className="w-3 h-3" />
{ext.githubRepo}
</a>
)}
</div>
</section>
{/* Screenshots */}
{ext.screenshots.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.screenshots.map((s, i) => (
<img
key={i}
src={s.url}
alt={s.altText || `Screenshot ${i + 1}`}
className="w-full rounded-md border border-border bg-muted"
loading="lazy"
/>
))}
</div>
</section>
)}
{/* Theme color preview */}
{!isPlugin && ext.themePreviews.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.themePreviews.map(preview => (
<ThemeColorSwatch key={preview.variant} preview={preview} />
))}
</div>
</section>
)}
{/* Permissions */}
{isPlugin && (
<section className="border border-border rounded-lg p-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
</div>
{manifestPerms.length === 0 ? (
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
) : (
<ul className="mt-3 space-y-1.5">
{manifestPerms.map(perm => {
const risky = RISKY_PERMISSIONS.has(perm);
return (
<li
key={perm}
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
risky
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted/50 text-foreground'
}`}
>
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
<code className="font-mono text-xs">{perm}</code>
</li>
);
})}
</ul>
)}
{frameOrigins.length > 0 && (
<div className="mt-4 pt-3 border-t border-border">
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
<p className="text-xs text-muted-foreground mt-0.5">
The plugin will be allowed to embed content from these origins.
</p>
<ul className="mt-2 space-y-1">
{frameOrigins.map(origin => (
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
{origin}
</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Settings schema preview */}
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">User settings</h2>
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
<ul className="mt-3 divide-y divide-border">
{Object.entries(settingsSchema).map(([key, field]) => (
<li key={key} className="py-2">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">{key}</code>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
</div>
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
{field.description && (
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
)}
</li>
))}
</ul>
</section>
)}
{/* Source / manifest disclosure */}
{bundle.manifest && (
<section className="border border-border rounded-lg">
<button
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"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
</div>
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showManifest && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
{JSON.stringify(bundle.manifest, null, 2)}
</pre>
)}
</section>
)}
{bundle.source && (
<section className="border border-border rounded-lg">
<button
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"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
{bundle.source.truncated && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
)}
</div>
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showSource && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
{bundle.source.content}
</pre>
)}
</section>
)}
{/* Version history */}
{ext.versions.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Version history</h2>
<ul className="mt-3 divide-y divide-border">
{ext.versions.slice(0, 5).map(v => (
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">v{v.version}</code>
{v.publishedAt && (
<span className="text-xs text-muted-foreground">
{new Date(v.publishedAt).toLocaleDateString()}
</span>
)}
</div>
{v.changelog && (
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{(v.bundleSize / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</section>
)}
</div>
);
}
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
const colors = preview.colors || {};
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
const accent = colors.primary || colors.accent || '#7c5cff';
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
{preview.variant}
</div>
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
</div>
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
<span style={{ color: fg }}>Card surface</span>
</div>
<div className="flex flex-wrap gap-1">
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
<span
key={key}
title={`${key}: ${value}`}
className="inline-block w-4 h-4 rounded border"
style={{ background: value, borderColor: border }}
/>
))}
</div>
</div>
</div>
);
}
+39 -28
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react'; import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface Extension { interface Extension {
@@ -141,8 +142,8 @@ export default function AdminMarketplacePage() {
)} )}
{/* Search & Filters */} {/* Search & Filters */}
<div className="flex items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1"> <div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" type="text"
@@ -152,7 +153,7 @@ export default function AdminMarketplacePage() {
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 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"
/> />
</div> </div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5"> <div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
{(['all', 'plugin', 'theme'] as const).map((t) => ( {(['all', 'plugin', 'theme'] as const).map((t) => (
<button <button
key={t} key={t}
@@ -262,10 +263,11 @@ function ExtensionCard({
onInstall: () => void; onInstall: () => void;
}) { }) {
const isPlugin = extension.type === 'plugin'; const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
return ( return (
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors"> <div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<div className="p-4"> <Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
{/* Header */} {/* Header */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
@@ -277,7 +279,9 @@ function ExtensionCard({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span> <span className="text-sm font-medium text-foreground truncate group-hover:underline">
{extension.name}
</span>
{extension.featured && ( {extension.featured && (
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" /> <Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
)} )}
@@ -315,7 +319,7 @@ function ExtensionCard({
</div> </div>
)} )}
{/* Footer */} {/* Footer (download count + permissions) */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border"> <div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -328,27 +332,34 @@ function ExtensionCard({
</span> </span>
)} )}
</div> </div>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
{extension.installed ? ( <Eye className="w-3 h-3" />
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"> Preview
<Check className="w-3 h-3" /> </span>
Installed
</span>
) : (
<button
onClick={onInstall}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Install
</button>
)}
</div> </div>
</Link>
{/* Quick install button (sits over the link, stops navigation) */}
<div className="px-4 pb-4 -mt-1">
{extension.installed ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" />
Installed
</span>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Quick install
</button>
)}
</div> </div>
</div> </div>
); );
+19 -1
View File
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
const [pluginCount, setPluginCount] = useState(0); const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0); const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0); const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown'); const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => { useEffect(() => {
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
}, []); }, []);
async function fetchDashboardData() { async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([ const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
apiFetch('/api/admin/auth'), apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'), apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'), apiFetch('/api/config'),
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
apiFetch('/api/admin/plugins').catch(() => null), apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null), apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null), apiFetch('/api/admin/policy').catch(() => null),
apiFetch('/api/admin/telemetry').catch(() => null),
]); ]);
if (statusRes.ok) setStatus(await statusRes.json()); if (statusRes.ok) setStatus(await statusRes.json());
@@ -73,6 +75,12 @@ export default function AdminDashboardPage() {
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0; const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
setPolicyRuleCount(restrictionCount + disabledGates); setPolicyRuleCount(restrictionCount + disabledGates);
} }
if (telemetryRes?.ok) {
const telemetry = await telemetryRes.json();
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
setAccountCounts(telemetry.accountCounts);
}
}
if (configData?.jmapServerUrl) { if (configData?.jmapServerUrl) {
try { try {
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
{/* Accounts */}
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
</SettingItem>
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
</SettingItem>
</SettingsSection>
{/* Extensions */} {/* Extensions */}
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules"> <SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins"> <SettingItem label="Plugins">
+9 -9
View File
@@ -262,12 +262,12 @@ export default function AdminPluginsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1> <h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p> <p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{policyDirty && ( {policyDirty && (
<button <button
onClick={handleSavePolicy} onClick={handleSavePolicy}
@@ -309,7 +309,7 @@ export default function AdminPluginsPage() {
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p> <p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Plugins Enabled</span> <span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p> <p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
@@ -320,7 +320,7 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">User Plugin Uploads</span> <span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
@@ -331,7 +331,7 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Require Admin Approval</span> <span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p> <p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
@@ -344,7 +344,7 @@ export default function AdminPluginsPage() {
{/* Force enable / disable all */} {/* Force enable / disable all */}
{plugins.length > 0 && ( {plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Force Enable / Disable All</span> <span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p> <p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
@@ -388,9 +388,9 @@ export default function AdminPluginsPage() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{plugins.map(plugin => ( {plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4"> <div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span> <span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span> <span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+8 -7
View File
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' }, customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' }, calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' }, smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' }, externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
@@ -131,8 +132,8 @@ export default function AdminPolicyPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1> <h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p> <p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div> </div>
@@ -169,13 +170,13 @@ export default function AdminPolicyPage() {
const { label, description } = meta; const { label, description } = meta;
const enabled = policy.features[key]; const enabled = policy.features[key];
return ( return (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p> <p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div> </div>
<button onClick={() => toggleFeature(key)} <button onClick={() => toggleFeature(key)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
@@ -194,9 +195,9 @@ export default function AdminPolicyPage() {
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {}; const restriction = policy.restrictions[setting.key] || {};
return ( return (
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span> <span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)} <input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" /> className="rounded border-input" />
+12 -12
View File
@@ -86,8 +86,8 @@ export default function AdminSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1> <h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p> <p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div> </div>
@@ -166,21 +166,21 @@ function TextSetting({ label, configKey, value, source, onChange, onRevert, plac
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label> <label className="text-sm text-foreground">{label}</label>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)} onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
{source === 'admin' && ( {source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default"> <button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
</button> </button>
)} )}
@@ -194,7 +194,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -202,7 +202,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={() => onChange(configKey, !value)} onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
@@ -224,12 +224,12 @@ function SelectSetting({ label, configKey, value, source, options, onChange, onR
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<select <select
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)} onChange={(e) => onChange(configKey, e.target.value)}
+250
View File
@@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
consentSource: 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
payloadPreview: Record<string, unknown>;
accountCounts: { total: number; active7d: number };
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
const days = Math.floor(h / 24);
return `${days} days ago`;
}
export default function AdminTelemetryPage() {
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/telemetry');
if (!r.ok) throw new Error('failed to load');
const data = (await r.json()) as TelemetryStatus;
setStatus(data);
setEndpointDraft(data.endpoint);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function sendNow(): Promise<void> {
setBusy('send');
setSendResult(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'send-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
setSendResult({
ok: !!j.ok,
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally { setBusy(null); }
}
if (loading || !status) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
rel="noreferrer"
className="underline inline-flex items-center gap-1"
>
Full schema and policy <ExternalLink className="h-3 w-3" />
</a>
</p>
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy === 'consent' || envOverridden || isOn}
onClick={() => void setConsent('on')}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Enable
</button>
<button
type="button"
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
onClick={() => void setConsent('off')}
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
>
Disable
</button>
</div>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
<dt className="text-muted-foreground">Last sent</dt>
<dd>{timeAgo(status.lastSentAt)}</dd>
<dt className="text-muted-foreground">Next scheduled</dt>
<dd>{timeAgo(status.nextScheduledAt)}</dd>
<dt className="text-muted-foreground">Consented at</dt>
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-2">
<div className="font-medium">Account activity</div>
<p className="text-sm text-muted-foreground">
Unique accounts that have logged in over the last 90 days. Identities are stored as a
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
heartbeat as bucketed ranges.
</p>
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
<dt className="text-muted-foreground">Total (90d)</dt>
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
<dt className="text-muted-foreground">Active (7d)</dt>
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="font-medium">Endpoint</div>
<p className="text-sm text-muted-foreground">
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
</div>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
</div>
</div>
<button
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
</div>
{sendResult && (
<div
className={`text-sm flex items-center gap-2 ${
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
}`}
>
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{sendResult.msg}
</div>
)}
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
{JSON.stringify(status.payloadPreview, null, 2)}
</pre>
</section>
</div>
);
}
+17 -17
View File
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1> <h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p> <p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{policyDirty && ( {policyDirty && (
<button <button
onClick={handleSavePolicy} onClick={handleSavePolicy}
@@ -356,37 +356,37 @@ export default function AdminThemesPage() {
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{/* Master toggle */} {/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Themes Enabled</span> <span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div> </div>
<button onClick={toggleThemesEnabled} <button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
{/* User uploads toggle */} {/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">User Theme Uploads</span> <span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div> </div>
<button onClick={toggleUserThemeUploads} <button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
{/* Force enable / disable all */} {/* Force enable / disable all */}
{themes.length > 0 && ( {themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span> <span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p> <p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={forceEnableAll} onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors" className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
@@ -407,15 +407,15 @@ export default function AdminThemesPage() {
{/* Default Theme */} {/* Default Theme */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span> <span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p> <p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div> </div>
<select <select
value={policy.themePolicy?.defaultThemeId || ''} value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)} onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground" className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
> >
<option value="">System Default</option> <option value="">System Default</option>
<optgroup label="Built-in"> <optgroup label="Built-in">
@@ -498,9 +498,9 @@ export default function AdminThemesPage() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{themes.map(theme => ( {themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4"> <div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span> <span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span> <span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+237
View File
@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity) {
switch (severity) {
case 'security':
return {
label: 'Security update',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'deprecated':
return {
label: 'Deprecated',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'normal':
return {
label: 'Update available',
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
Icon: AlertTriangle,
};
case 'unknown':
return {
label: 'Unknown',
className: 'bg-muted text-muted-foreground border-border',
Icon: AlertTriangle,
};
case 'none':
default:
return {
label: 'Up to date',
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
Icon: CheckCircle2,
};
}
}
export default function AdminVersionPage() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setCheckResult(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setCheckResult({
ok: !!j.ok,
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally {
setChecking(false);
}
}
if (loading || !data) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}
Binary file not shown.
@@ -53,11 +53,19 @@ export async function GET(
const buffer = await readFile(resolved); const buffer = await readFile(resolved);
// SVG can carry inline <script> and event handlers that execute when the
// file is fetched as a top-level document. Defense in depth on top of
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
// sandboxed unique origin so any script in an SVG is inert and cannot
// touch app cookies or storage.
return new NextResponse(buffer, { return new NextResponse(buffer, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate', 'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length), 'Content-Length': String(buffer.length),
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy':
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
}, },
}); });
} catch { } catch {
+214
View File
@@ -0,0 +1,214 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
getPluginRegistry,
getThemeRegistry,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
const MAX_PREVIEW_SOURCE_LEN = 100_000;
/**
* GET /api/admin/marketplace/[slug]
* Returns full preview info for an extension: directory metadata,
* the bundle's manifest, a (truncated) source preview, and install status.
* Lets admins audit what they're about to install before pressing the button.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { slug } = await params;
// 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!detailRes.ok) {
const status = detailRes.status === 404 ? 404 : 502;
return NextResponse.json(
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
{ status },
);
}
const detailJson = await detailRes.json();
const extension = detailJson.data as Record<string, unknown> | undefined;
if (!extension) {
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
}
const type = extension.type as 'plugin' | 'theme';
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
?? null;
// 2. Pull the bundle so we can show what's actually inside.
let manifest: Record<string, unknown> | null = null;
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
let bundleError: string | null = null;
let bundleSize = 0;
if (latestVersion) {
try {
const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL,
);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
if (!bundleRes.ok) {
bundleError = `Bundle download failed (${bundleRes.status})`;
} else {
const buffer = await bundleRes.arrayBuffer();
bundleSize = buffer.byteLength;
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
if (buffer.byteLength > maxSize) {
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
} else {
const zip = await JSZip.loadAsync(buffer);
// Detect optional root directory inside the ZIP.
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
bundleError = 'Bundle missing manifest.json';
} else {
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
bundleError = 'Invalid manifest.json in bundle';
}
}
if (manifest) {
if (type === 'theme') {
const cssFile = zip.file(root + 'theme.css');
if (cssFile) {
const css = await cssFile.async('string');
sourcePreview = {
name: 'theme.css',
content: css.length > MAX_PREVIEW_SOURCE_LEN
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
: css,
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
};
}
} else {
const entrypoint = (manifest.entrypoint as string) || 'index.js';
const jsFile = zip.file(root + entrypoint);
if (jsFile) {
const code = await jsFile.async('string');
sourcePreview = {
name: entrypoint,
content: code.length > MAX_PREVIEW_SOURCE_LEN
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
: code,
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
};
}
}
}
}
}
} catch (err) {
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
}
} else {
bundleError = 'Extension has no published version';
}
// 3. Install status (slug is used as the registry id at install time)
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
const installed = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.id === slug);
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
altText: s.altText ?? null,
}))
: [];
// Strip the heavy `manifest` blob from versions when echoing the directory data.
const versions = Array.isArray(extension.versions)
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
version: v.version,
changelog: v.changelog,
bundleSize: v.bundleSize,
minAppVersion: v.minAppVersion,
publishedAt: v.publishedAt,
permissions: v.permissions,
}))
: [];
return NextResponse.json(
{
extension: {
slug: extension.slug,
name: extension.name,
type: extension.type,
pluginType: extension.pluginType ?? null,
description: extension.description,
longDescription: extension.longDescription ?? null,
tags: extension.tags ?? [],
permissions: extension.permissions ?? [],
totalDownloads: extension.totalDownloads ?? 0,
featured: extension.featured ?? false,
githubRepo: extension.githubRepo ?? null,
license: extension.license ?? null,
minAppVersion: extension.minAppVersion ?? null,
author: extension.author ?? null,
latestVersion,
versions,
screenshots,
themePreviews: extension.themePreviews ?? [],
createdAt: extension.createdAt ?? null,
updatedAt: extension.updatedAt ?? null,
},
bundle: {
manifest,
source: sourcePreview,
size: bundleSize,
error: bundleError,
},
installed,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
logger.error('Marketplace preview error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to load preview' },
{ status: 502 },
);
}
}
+26 -2
View File
@@ -10,11 +10,15 @@ import {
type ServerPlugin, type ServerPlugin,
type ServerTheme, type ServerTheme,
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001'; const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
/** /**
* GET /api/admin/marketplace - Search/browse the extension directory * GET /api/admin/marketplace - Search/browse the extension directory
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`); warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
} }
// Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently
// dropped - the plugin still installs, but those origins are not
// added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
? (manifest.frameOrigins as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
)
: [];
if (droppedFrameOrigins.length > 0) {
warnings.push(
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
);
}
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
id: (manifest.id as string) || slug, id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
enabled: true, enabled: true,
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip); invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ success: true, plugin, warnings }); return NextResponse.json({ success: true, plugin, warnings });
} }
+253
View File
@@ -0,0 +1,253 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { locales as ALL_LOCALES } from '@/i18n/routing';
const CLIENT_ID = 'bulwark-webmail';
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
const JMAP_TIMEOUT_MS = 10_000;
interface JmapMethodCall {
using: string[];
methodCalls: Array<[string, Record<string, unknown>, string]>;
}
interface JmapMethodResponse {
methodResponses?: Array<[string, Record<string, unknown>, string]>;
}
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function jmapCall(
serverUrl: string,
authHeader: string,
body: JmapMethodCall,
): Promise<JmapMethodResponse> {
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
method: 'POST',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
}
return res.json() as Promise<JmapMethodResponse>;
}
async function getStalwartAccountId(
serverUrl: string,
authHeader: string,
): Promise<string | null> {
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': authHeader },
});
if (!res.ok) return null;
const session = await res.json() as { primaryAccounts?: Record<string, string> };
return session.primaryAccounts?.['urn:stalwart:jmap']
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
?? Object.values(session.primaryAccounts ?? {})[0]
?? null;
}
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
const out: Record<string, true> = {};
for (const loc of localeList) {
out[`${origin}/${loc}/auth/callback`] = true;
}
return out;
}
interface SetupRequestBody {
origin?: string;
issuerUrl?: string;
locales?: string[];
oauthOnly?: boolean;
}
function isValidOriginUrl(value: string): boolean {
return /^https?:\/\/[^/]+$/.test(value);
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json(
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
{ status: 400 },
);
}
const body = await request.json() as SetupRequestBody;
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
if (!isValidOriginUrl(origin)) {
return NextResponse.json(
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
{ status: 400 },
);
}
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
if (!isValidOriginUrl(issuerUrl)) {
return NextResponse.json(
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
{ status: 400 },
);
}
const localeList = Array.isArray(body.locales) && body.locales.length > 0
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
: Array.from(ALL_LOCALES);
if (localeList.length === 0) {
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
}
const oauthOnly = body.oauthOnly === true;
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
if (!accountId) {
return NextResponse.json(
{ error: 'Could not resolve Stalwart account from JMAP session.' },
{ status: 502 },
);
}
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [[
'x:OAuthClient/query',
{ accountId, filter: { clientId: CLIENT_ID } },
'0',
]],
});
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
detail: queryEntry?.[1],
}, { status: 403 });
}
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
const secret = randomBytes(32).toString('base64url');
const redirectUris = buildRedirectUris(origin, localeList);
let setArgs: Record<string, unknown>;
let action: 'created' | 'updated';
if (existingIds.length > 0) {
const targetId = existingIds[0];
action = 'updated';
setArgs = {
accountId,
update: {
[targetId]: {
secret,
redirectUris,
description: CLIENT_DESCRIPTION,
},
},
};
} else {
action = 'created';
setArgs = {
accountId,
create: {
new: {
clientId: CLIENT_ID,
description: CLIENT_DESCRIPTION,
secret,
redirectUris,
contacts: { [creds.username]: true },
},
},
};
}
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
});
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
detail: setEntry?.[1],
}, { status: 403 });
}
const setBody = setEntry[1] as {
notCreated?: Record<string, unknown>;
notUpdated?: Record<string, unknown>;
};
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
{ status: 502 },
);
}
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
{ status: 502 },
);
}
await configManager.ensureLoaded();
const updates: Record<string, unknown> = {
oauthEnabled: true,
oauthClientId: CLIENT_ID,
oauthClientSecret: secret,
oauthIssuerUrl: issuerUrl,
};
if (oauthOnly) updates.oauthOnly = true;
await configManager.setAdminConfig(updates);
await auditLog('admin.oauth_setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
redirectUriCount: localeList.length,
oauthOnly,
}, ip);
logger.info('Admin OAuth setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
locales: localeList.length,
});
return NextResponse.json({
ok: true,
action,
clientId: CLIENT_ID,
origin,
issuerUrl,
redirectUriCount: localeList.length,
});
} catch (error) {
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 },
);
}
}
+31 -5
View File
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry'; import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session'; import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/** /**
* GET /api/admin/plugins/[id]/config - Read all config for a plugin * GET /api/admin/plugins/[id]/config - Read plugin config
* *
* Returns the full config object for admin-configured plugin settings. * - Admin sessions receive every field, including those declared
* This endpoint is accessible from the client-side plugin API. * `type: 'secret'` in the plugin's configSchema.
* - Authenticated mailbox users (the plugin running in their browser)
* receive only non-secret fields.
* - Anonymous callers are rejected so unauthenticated visitors cannot
* enumerate plugin secrets.
*/ */
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
@@ -20,13 +25,34 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
} }
const adminAuth = await requireAdminAuth();
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
}
const plugin = await getPlugin(id); const plugin = await getPlugin(id);
if (!plugin) { if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
const config = await getPluginConfig(id); const config = await getPluginConfig(id);
return NextResponse.json(config, {
let response: Record<string, unknown> = config;
if (!isAdmin && plugin.configSchema) {
response = {};
for (const [key, value] of Object.entries(config)) {
const field = plugin.configSchema[key];
if (field?.type === 'secret') continue;
response[key] = value;
}
}
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch { } catch {
+17 -1
View File
@@ -8,6 +8,10 @@ import {
deletePlugin as removePlugin, deletePlugin as removePlugin,
type ServerPlugin, type ServerPlugin,
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
// Server-side extraction using the same validation logic // Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins // ZIP parsing needs to happen on the server for admin-uploaded plugins
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
); );
} }
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const now = new Date().toISOString(); const now = new Date().toISOString();
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
id: manifest.id as string, id: manifest.id as string,
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
...(manifest.configSchema && typeof manifest.configSchema === 'object' ...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}), : {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip); invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ plugin }); return NextResponse.json({ plugin });
} catch (error) { } catch (error) {
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
// Enable/disable changes the set of plugins contributing frame origins.
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
invalidateFrameOriginsCache();
}
await auditLog('plugin.update', { id, ...updates }, ip); await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated }); return NextResponse.json({ plugin: updated });
} catch (error) { } catch (error) {
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip); await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
+135
View File
@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
effectiveConsent,
loadState,
saveState,
buildPayload,
sendOnce,
reschedule,
DEFAULT_ENDPOINT,
getLoginCounts,
resolveEndpointAllowed,
} from '@/lib/telemetry';
/**
* GET /api/admin/telemetry
* Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain.
*/
export async function GET() {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
const [payload, accountCounts] = await Promise.all([
buildPayload(),
getLoginCounts(),
]);
return NextResponse.json(
{
consent,
consentSource: source,
endpoint: state.endpoint || DEFAULT_ENDPOINT,
consentedAt: state.consentedAt,
lastSentAt: state.lastSentAt,
nextScheduledAt: state.nextScheduledAt,
defaultEndpoint: DEFAULT_ENDPOINT,
payloadPreview: payload,
accountCounts,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('telemetry GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/telemetry
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
* set-consent : { action, consent: 'on' | 'off' }
* set-endpoint : { action, endpoint: string }
* send-now : { action }
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const body = (await request.json().catch(() => null)) as
| { action?: string; consent?: string; endpoint?: string }
| null;
if (!body || typeof body.action !== 'string') {
return NextResponse.json({ error: 'action required' }, { status: 400 });
}
const { source } = await effectiveConsent();
if (body.action === 'set-consent') {
if (source === 'env') {
return NextResponse.json(
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
{ status: 409 },
);
}
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
const state = await loadState();
const before = state.consent;
state.consent = body.consent;
if (body.consent === 'on' && !state.consentedAt) {
state.consentedAt = new Date().toISOString();
}
await saveState(state);
await reschedule();
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
return NextResponse.json({ ok: true });
}
if (body.action === 'set-endpoint') {
if (typeof body.endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
}
const trimmed = body.endpoint.trim();
if (trimmed) {
const check = await resolveEndpointAllowed(trimmed);
if (!check.ok) {
return NextResponse.json({ error: check.reason }, { status: 400 });
}
}
const state = await loadState();
const before = state.endpoint;
state.endpoint = trimmed || DEFAULT_ENDPOINT;
await saveState(state);
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
return NextResponse.json({ ok: true, endpoint: state.endpoint });
}
if (body.action === 'send-now') {
const result = await sendOnce({ reason: 'admin-manual' });
await auditLog(
'telemetry.send-now',
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
ip,
);
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (err) {
logger.error('telemetry POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
loadState,
checkOnce,
effectiveEndpoint,
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
/**
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET() {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const state = await loadState();
return NextResponse.json(
{
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
status: state.status,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('version admin GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
/**
* POST /api/admin/version
* { action: 'check-now' } — force a fresh upstream fetch.
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
if (!body || body.action !== 'check-now') {
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
}
const result = await checkOnce({ reason: 'admin-trigger' });
return NextResponse.json(result);
} catch (err) {
logger.error('version admin POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
+36 -2
View File
@@ -9,6 +9,9 @@ import {
clearStalwartAuthContextInStore, clearStalwartAuthContextInStore,
setStalwartAuthContextInStore, setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context'; } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -25,7 +28,9 @@ function getSlot(request: NextRequest): number {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') { const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
if (oauthEnabled && oauthOnly) {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 }); return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
} }
@@ -34,10 +39,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader); const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
const token = encryptSession(normalizedServerUrl, username, password); const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS); cookieStore.set(cookieName, token, COOKIE_OPTIONS);
@@ -47,6 +79,8 @@ export async function POST(request: NextRequest) {
authHeader, authHeader,
}); });
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof JmapAuthVerificationError) { if (error instanceof JmapAuthVerificationError) {
+9 -3
View File
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
const cookieStore = await cookies(); const cookieStore = await cookies();
try { try {
const { code, state } = await request.json(); const { code, state, slot: bodySlot } = await request.json();
if (!code || !state) { if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 }); return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
} }
// Per-account refresh-token cookie slot. Without this the route hardcoded
// slot 0, so the "+ Add Account" flow overwrote the first account's
// refresh-token cookie. Default to 0 for back-compat with any caller that
// omits slot. Mirrors the validation in /api/auth/token POST.
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Read and decrypt the pending SSO cookie // Read and decrypt the pending SSO cookie
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) { if (!pendingCookie) {
@@ -58,9 +64,9 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens // Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri); const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
// Store refresh token // Store refresh token in the per-account cookie slot.
if (tokens.refresh_token) { if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(0); const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
} }
+33 -1
View File
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number { function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
@@ -23,8 +26,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = getSlot(request, bodySlot); const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader); const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
await setStalwartAuthContext(slot, { await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
@@ -32,6 +62,8 @@ export async function POST(request: NextRequest) {
authHeader, authHeader,
}); });
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof JmapAuthVerificationError) { if (error instanceof JmapAuthVerificationError) {
+39 -14
View File
@@ -5,6 +5,9 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -83,22 +86,39 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the // Pin the upstream URL to the configured JMAP server so an unauthenticated
// public URL the browser uses, e.g. inside Docker). // caller cannot point this route at internal hosts. Only when no server
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl; // URL is configured (and the deployment explicitly allows custom JMAP
// endpoints) do we fall back to the user-supplied URL - and even then
// it must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const tokenEndpoint = await findTokenEndpoint(internalServerUrl); let upstreamUrl: string;
if (!tokenEndpoint) { if (configuredServerUrl) {
// Also try with the client-provided URL in case the internal one differs upstreamUrl = configuredServerUrl;
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null; } else if (allowCustomEndpoint) {
if (!clientEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl }); logger.warn('TOTP token exchange: rejected non-public server URL');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
} }
return await attemptAllStrategies(clientEndpoint, username, password, slot); upstreamUrl = serverUrl;
} else {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
} }
return await attemptAllStrategies(tokenEndpoint, username, password, slot); const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
} catch (error) { } catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('TOTP token exchange 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 });
@@ -107,14 +127,15 @@ export async function POST(request: NextRequest) {
async function attemptAllStrategies( async function attemptAllStrategies(
tokenEndpoint: string, tokenEndpoint: string,
serverUrl: string,
username: string, username: string,
password: string, password: string,
slot: number, slot: number,
): Promise<NextResponse> { ): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID; const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); const 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 basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = []; const attempts: Array<{ strategy: string; error: string }> = [];
@@ -125,6 +146,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params); const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)'); logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'ROPC with client_id', error: result.error }); attempts.push({ strategy: 'ROPC with client_id', error: result.error });
@@ -136,6 +158,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params); const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)'); logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'ROPC without client_id', error: result.error }); attempts.push({ strategy: 'ROPC without client_id', error: result.error });
@@ -147,6 +170,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)'); logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'Basic Auth header', error: result.error }); attempts.push({ strategy: 'Basic Auth header', error: result.error });
@@ -158,6 +182,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)'); logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error }); attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
+3 -84
View File
@@ -1,89 +1,9 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000; const FETCH_TIMEOUT_MS = 15000;
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.endsWith('.localdomain')
) {
return false;
}
// Block URLs with credentials
if (url.username || url.password) {
return false;
}
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let body: { url?: string }; let body: { url?: string };
try { try {
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 }); return NextResponse.json({ error: 'URL is required' }, { status: 400 });
} }
if (!(await isValidExternalUrl(url))) { if (!(await isPublicHttpUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
} }
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
let response: Response | undefined; let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) { for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isValidExternalUrl(currentUrl))) { if (!(await isPublicHttpUrl(currentUrl))) {
clearTimeout(timeout); clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
} }
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
clearTimeout(timeout); clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 }); return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
} }
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString(); currentUrl = new URL(location, currentUrl).toString();
continue; continue;
} }
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/push/preview
*
* Called from the service worker when a Web Push wake-up arrives. Fetches the
* latest unread email so the SW can build an enriched system notification
* (sender, subject, avatar) without ever exposing JMAP credentials to the
* SW context.
*
* The relay's push payload is intentionally minimal (just a state-change
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
* a generic "New mail" string.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
}
const inboxRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[
'Mailbox/query',
{ accountId, filter: { role: 'inbox' }, limit: 1 },
'mb',
],
],
}),
});
if (!inboxRes.ok) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxData = (await inboxRes.json()) as {
methodResponses: [string, Record<string, unknown>, string][];
};
const inboxBody = inboxData.methodResponses.find(
([method]) => method === 'Mailbox/query',
)?.[1] as { ids?: string[] } | undefined;
const inboxId = inboxBody?.ids?.[0];
if (!inboxId) {
return NextResponse.json({
email: null,
unreadTotal: 0,
}, {
headers: {
'Cache-Control': 'no-store',
},
});
}
// Pull the most recent unread message from the resolved Inbox mailbox.
const requestBody = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[
'Email/query',
{
accountId,
filter: {
operator: 'AND',
conditions: [
{ inMailbox: inboxId },
{ notKeyword: '$seen' },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 1,
calculateTotal: true,
},
'eq',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
},
'eg',
],
],
};
const jmapRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!jmapRes.ok) {
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
}
const data = (await jmapRes.json()) as {
methodResponses: [string, Record<string, unknown>, string][];
};
type EmailLite = {
id: string;
threadId: string;
from?: { name?: string | null; email?: string }[] | null;
subject?: string | null;
preview?: string | null;
receivedAt?: string | null;
};
let email: EmailLite | null = null;
let unreadTotal = 0;
for (const [method, body] of data.methodResponses) {
if (method === 'Email/query') {
unreadTotal = ((body as { total?: number }).total) ?? 0;
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
email = list[0] ?? null;
}
}
return NextResponse.json({
email,
unreadTotal,
}, {
headers: {
// SW already gates on its own logic - don't let push events get
// cached and served stale.
'Cache-Control': 'no-store',
},
});
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', {
error: err?.message ?? 'Unknown error',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { checkOnce, loadState } from '@/lib/version-check';
// Public endpoint that returns the latest cached update status. Fed by the
// background scheduler started in instrumentation.node.ts; in production we
// never trigger a fresh upstream fetch from this route so an unauthenticated
// client can't use it to amplify traffic to the version server.
//
// In development we force a fresh fetch on every hit so changes to the
// version server's overrides take effect on the next page reload instead of
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
// caps the worst-case latency added to a dev reload.
export async function GET() {
if (process.env.NODE_ENV === 'development') {
await checkOnce({ reason: 'dev-reload' });
}
const state = await loadState();
return NextResponse.json(
{
status: state.status,
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
},
{
headers: {
'Cache-Control': 'no-store',
},
},
);
}
+85
View File
@@ -204,6 +204,31 @@ body {
min-width: 100%; min-width: 100%;
} }
/* Forces light-theme CSS variables inside the email content area, so when
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
sender info / attachments / plain-text body don't end up with light text
on a white background. */
.email-content-light {
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
--color-secondary: #f8fafc;
--color-secondary-foreground: #0f172a;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
--color-input: #e2e8f0;
}
.email-content-light .email-content-text a {
color: #2563eb;
}
.email-content-text { .email-content-text {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
@@ -497,6 +522,15 @@ body {
} }
} }
.scroll-hidden::-webkit-scrollbar {
display: none;
}
.scroll-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Mobile backdrop blur support */ /* Mobile backdrop blur support */
@supports (backdrop-filter: blur(8px)) { @supports (backdrop-filter: blur(8px)) {
.mobile-backdrop { .mobile-backdrop {
@@ -680,3 +714,54 @@ body {
.tiptap .ProseMirror-selectednode img { .tiptap .ProseMirror-selectednode img {
outline: none; outline: none;
} }
.tiptap table {
border-collapse: collapse;
margin: 0.5rem 0;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.tiptap table td,
.tiptap table th {
border: 1px solid var(--color-border);
padding: 0.375rem 0.5rem;
vertical-align: top;
position: relative;
min-width: 1em;
}
.tiptap table th {
background-color: var(--color-muted) !important;
color: var(--color-foreground) !important;
font-weight: 600;
text-align: left;
}
.tiptap table p {
margin: 0;
}
.tiptap table .selectedCell::after {
background: rgba(99, 102, 241, 0.15);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
.tiptap table .column-resize-handle {
background-color: var(--color-primary);
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
}
.tiptap.resize-cursor {
cursor: col-resize;
}
+20 -14
View File
@@ -2,6 +2,12 @@ import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Manifest paths must include the deployment subpath - browsers resolve them
// against the document origin, not the manifest's location, and Next.js does
// not auto-prefix string literals inside MetadataRoute payloads.
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
const appName = const appName =
process.env.APP_NAME || process.env.APP_NAME ||
@@ -21,26 +27,26 @@ export default function manifest(): MetadataRoute.Manifest {
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [ ? [
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" }, { src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" }, { src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" }, { src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
] ]
: [ : [
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" }, { src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" }, { src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
]; ];
return { return {
name: appName, name: appName,
short_name: shortName, short_name: shortName,
description, description,
start_url: "/", start_url: withBase("/"),
scope: "/", scope: withBase("/"),
display: "standalone", display: "standalone",
orientation: "portrait-primary", orientation: "portrait-primary",
theme_color: themeColor, theme_color: themeColor,
@@ -48,8 +54,8 @@ export default function manifest(): MetadataRoute.Manifest {
icons, icons,
categories: ["productivity"], categories: ["productivity"],
screenshots: [ screenshots: [
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" }, { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" }, { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
], ],
}; };
} }
+16 -7
View File
@@ -2,34 +2,43 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
export default function NotFound() { export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
// Don't redirect admin routes to the webmail login page const prefix = getPathPrefix();
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'); // Don't redirect admin routes to the webmail login page. Admin paths
// are mounted relative to the deployment prefix, so account for it.
const adminBase = `${prefix}/admin`;
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
if (!isAdminRoute) { if (!isAdminRoute) {
window.location.href = "/login"; window.location.href = `${prefix}/login`;
} }
} }
}, [isAuthenticated]); }, [isAuthenticated]);
if (!isAuthenticated) { if (!isAuthenticated) {
// Allow admin routes to render the 404 without redirecting let isAdmin = false;
const isAdmin = typeof window !== 'undefined' && if (typeof window !== 'undefined') {
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/')); const prefix = getPathPrefix();
const adminBase = `${prefix}/admin`;
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
}
if (!isAdmin) return null; if (!isAdmin) return null;
} }
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4"> <div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1> <h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p> <p className="text-muted-foreground mb-6">This page could not be found.</p>
<a <a
href="/" href={`${prefix}/`}
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
> >
Go home Go home
+10 -1
View File
@@ -20,6 +20,7 @@ interface CalendarDayViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
@@ -39,6 +40,7 @@ export function CalendarDayView({
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent, onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime, onCreateAtTime,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
@@ -144,7 +146,13 @@ export function CalendarDayView({
</div> </div>
{(allDayEvents.length > 0 || dayTasks.length > 0) && ( {(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div className="px-4 py-2 border-b border-border"> <div
className="px-4 py-2 border-b border-border"
onContextMenu={onContextMenuEmpty ? (e) => {
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
onContextMenuEmpty(e, selectedDate, undefined, true);
} : undefined}
>
{allDayEvents.length > 0 && ( {allDayEvents.length > 0 && (
<> <>
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div> <div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
@@ -240,6 +248,7 @@ export function CalendarDayView({
aria-label={formatHour(h)} aria-label={formatHour(h)}
onClick={() => handleSlotClick(selectedDate, h)} onClick={() => handleSlotClick(selectedDate, h)}
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)} onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }} style={{ height: HOUR_HEIGHT }}
/> />
@@ -24,6 +24,7 @@ interface CalendarMonthViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void; onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
isMobile?: boolean; isMobile?: boolean;
@@ -39,6 +40,7 @@ export function CalendarMonthView({
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent, onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
isMobile, isMobile,
@@ -174,6 +176,7 @@ export function CalendarMonthView({
aria-label={fullDateLabel} aria-label={fullDateLabel}
onClick={() => onSelectDate(day)} onClick={() => onSelectDate(day)}
onDoubleClick={() => onCreateAtTime?.(day)} onDoubleClick={() => onCreateAtTime?.(day)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
onDragOver={(e) => handleCellDragOver(e, key)} onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave} onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)} onDrop={(e) => handleCellDrop(e, day)}
+133 -105
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useRef, useEffect, useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react"; import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps { interface CalendarSidebarPanelProps {
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[]; selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onShareCalendar?: (calendar: Calendar) => void;
onCreateEvent?: (calendar: Calendar) => void;
onClearCalendar?: (calendar: Calendar) => void;
onDeleteCalendar?: (calendar: Calendar) => void;
onCreateCalendar?: () => void;
onSubscribe?: () => void; onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void; onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null; client?: IJMAPClient | null;
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onShareCalendar,
onCreateEvent,
onClearCalendar,
onDeleteCalendar,
onCreateCalendar,
onSubscribe, onSubscribe,
onEditSubscription, onEditSubscription,
client, client,
}: CalendarSidebarPanelProps) { }: CalendarSidebarPanelProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription"); const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions); const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]); }, [tasks]);
const [colorPickerId, setColorPickerId] = useState<string | null>(null); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null); const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => { const sharedAccountGroups = useMemo(() => {
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
return Array.from(groups.values()); return Array.from(groups.values());
}, [calendars]); }, [calendars]);
useEffect(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuCalId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setColorPickerId(null);
setContextMenuCalId(null);
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId, contextMenuCalId]);
const getSubscriptionForCalendar = (calendarId: string) => { const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId); return icalSubscriptions.find(s => s.calendarId === calendarId);
}; };
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
const handleRefreshSubscription = async (subId: string) => { const handleRefreshSubscription = async (subId: string) => {
if (!client) return; if (!client) return;
setRefreshingSubId(subId); setRefreshingSubId(subId);
setContextMenuCalId(null);
try { try {
await refreshICalSubscription(client, subId); await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success')); toast.success(tSub('refresh_success'));
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
const handleUnsubscribe = async (subId: string) => { const handleUnsubscribe = async (subId: string) => {
if (!client) return; if (!client) return;
setContextMenuCalId(null);
try { try {
await removeICalSubscription(client, subId); await removeICalSubscription(client, subId);
toast.success(tSub('deleted')); toast.success(tSub('deleted'));
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
const renderCalendarItem = (cal: Calendar) => { const renderCalendarItem = (cal: Calendar) => {
const isVisible = selectedCalendarIds.includes(cal.id); const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6"; const color = cal.color || "#3b82f6";
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
return ( return (
<div key={cal.id} className="relative"> <div key={cal.id} className="relative">
<button <button
onClick={() => onToggleVisibility(cal.id)} onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => { onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn( className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150", "flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted" "hover:bg-muted"
@@ -168,71 +144,108 @@ export function CalendarSidebarPanel({
{cal.id === BIRTHDAY_CALENDAR_ID && ( {cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" /> <Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)} )}
</button> {!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (
<Users
{/* Subscription context menu on right-click */} className="w-3 h-3 text-muted-foreground flex-shrink-0"
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { aria-label={tMgmt('share')}
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/> />
</div> )}
)} </button>
</div> </div>
); );
}; };
const renderCalendarMenu = () => {
const cal = contextMenu.data;
if (!cal) return null;
if (isSubscriptionCalendar(cal.id)) {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub || !client) return null;
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem
icon={Pencil}
label={tSub('edit')}
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
/>
<ContextMenuItem
icon={RefreshCw}
label={tSub('refresh')}
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={tSub('unsubscribe')}
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
destructive
/>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</ContextMenu>
);
}
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canChangeColor = !!onColorChange;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{canCreate && (
<ContextMenuItem
icon={Plus}
label={tMgmt('new_event_in_calendar')}
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={tMgmt('share')}
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
<CalendarColorPicker
value={color}
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
allowCustom
/>
</div>
</ContextMenuSubMenu>
)}
{showSeparator && <ContextMenuSeparator />}
{canClear && (
<ContextMenuItem
icon={Eraser}
label={tMgmt('clear_events')}
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
/>
)}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={tMgmt('delete')}
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
destructive
/>
)}
</ContextMenu>
);
};
return ( return (
<div className="mt-4"> <div className="mt-4">
{enableCalendarTasks && ( {enableCalendarTasks && (
@@ -250,9 +263,22 @@ export function CalendarSidebarPanel({
)} )}
</button> </button>
)} )}
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1"> <div className="flex items-center justify-between mb-2 px-1 group">
{t("my_calendars")} {onCreateCalendar ? (
</h3> <button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)} {personalCalendars.map(renderCalendarItem)}
</div> </div>
@@ -268,6 +294,8 @@ export function CalendarSidebarPanel({
</div> </div>
</div> </div>
))} ))}
{renderCalendarMenu()}
</div> </div>
); );
} }
+7 -4
View File
@@ -260,6 +260,9 @@ 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">
{t("views.today")}
</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")}>
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</Button> </Button>
@@ -279,14 +282,14 @@ export function CalendarToolbar({
<div className="flex-1" /> <div className="flex-1" />
{!isMobile && ( {!isMobile && (
<div className="flex border border-border rounded-md overflow-hidden"> <div className="flex h-8 border border-border rounded-md overflow-hidden">
{views.map((v) => ( {views.map((v) => (
<button <button
key={v} key={v}
onClick={() => onViewModeChange(v)} onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)} title={t(`views.${v}_hint`)}
className={cn( className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors", "inline-flex items-center px-3 text-xs font-medium transition-colors",
v === viewMode v === viewMode
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground" : "hover:bg-muted text-muted-foreground"
@@ -300,7 +303,7 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && ( {(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}> <div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" 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 mr-1" />
{t("import.title")} {t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" /> <ChevronDown className="w-3 h-3 ml-1" />
@@ -331,7 +334,7 @@ export function CalendarToolbar({
)} )}
{!isMobile && ( {!isMobile && (
<Button size="sm" 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 mr-1" />
{t("events.create")} {t("events.create")}
</Button> </Button>
+8 -1
View File
@@ -23,6 +23,7 @@ interface CalendarWeekViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
@@ -44,6 +45,7 @@ export function CalendarWeekView({
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent, onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
timeFormat = "24h", timeFormat = "24h",
@@ -219,7 +221,11 @@ export function CalendarWeekView({
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }} style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
> >
{weekDays.map((day) => ( {weekDays.map((day) => (
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" /> <div
key={format(day, "yyyy-MM-dd")}
className="bg-background min-h-[28px]"
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
/>
))} ))}
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 pointer-events-none">
@@ -378,6 +384,7 @@ export function CalendarWeekView({
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`} aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
onClick={() => handleSlotClick(day, h)} onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }} style={{ height: HOUR_HEIGHT }}
/> />
@@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface CreateCalendarModalProps {
client: IJMAPClient;
onClose: () => void;
}
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
const t = useTranslations("calendar.management");
const tCommon = useTranslations("common");
const createCalendar = useCalendarStore((s) => s.createCalendar);
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [isSubmitting, setIsSubmitting] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const isValid = name.trim().length > 0;
const handleSubmit = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
const created = await createCalendar(client, { name: trimmed, color });
if (created) {
toast.success(t("calendar_created"));
onClose();
} else {
toast.error(t("error_create"));
}
} catch {
toast.error(t("error_create"));
} finally {
setIsSubmitting(false);
}
}, [name, color, client, createCalendar, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSubmitting) onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, isSubmitting]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
onClick={() => !isSubmitting && onClose()}
aria-hidden="true"
/>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("add_calendar")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
</div>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{tCommon("loading")}
</>
) : (
t("create")
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
interface Position {
x: number;
y: number;
}
interface EmptySpaceContextMenuProps {
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onNewEvent: () => void;
onNewAllDayEvent: () => void;
onNewTask?: () => void;
onGoToToday: () => void;
showAllDayOption?: boolean;
}
export function EmptySpaceContextMenu({
position,
isOpen,
onClose,
menuRef,
onNewEvent,
onNewAllDayEvent,
onNewTask,
onGoToToday,
showAllDayOption = true,
}: EmptySpaceContextMenuProps) {
const t = useTranslations("calendar");
const handle = (fn: () => void) => () => {
fn();
onClose();
};
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
{showAllDayOption && (
<ContextMenuItem
icon={CalendarDays}
label={t("events.new_all_day_event")}
onClick={handle(onNewAllDayEvent)}
/>
)}
{onNewTask && (
<ContextMenuItem
icon={CheckSquare}
label={t("events.new_task")}
onClick={handle(onNewTask)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
</ContextMenu>
);
}
+2 -3
View File
@@ -150,9 +150,8 @@ 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 px-1.5 py-0.5 text-xs overflow-hidden", "w-full h-full text-left 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",
continuesBefore && "rounded-l-sm",
continuesAfter && "rounded-r-sm", continuesAfter && "rounded-r-sm",
continuesBefore && "-ml-0.5", continuesBefore && "-ml-0.5",
continuesAfter && "pr-2", continuesAfter && "pr-2",
@@ -182,7 +181,7 @@ 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 px-1.5 py-0.5 text-xs overflow-hidden", "w-full h-full text-left 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",
+4 -1
View File
@@ -19,6 +19,7 @@ import {
getUserStatus, getUserStatus,
getParticipantList, getParticipantList,
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
interface EventDetailPopoverProps { interface EventDetailPopoverProps {
event: CalendarEvent; event: CalendarEvent;
@@ -253,6 +254,8 @@ export function EventDetailPopover({
const hasParticipants = participants.length > 0; const hasParticipants = participants.length > 0;
const formatEventDate = useFormatEventDate();
const popover = ( const popover = (
<div <div
ref={popoverRef} ref={popoverRef}
@@ -329,7 +332,7 @@ export function EventDetailPopover({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(startDate, "EEE, MMM d, yyyy")} {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 ml-1.5">{t("events.all_day")}</span>
+161 -107
View File
@@ -21,6 +21,9 @@ import {
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
export interface PendingEventPreview { export interface PendingEventPreview {
start: Date; start: Date;
@@ -35,6 +38,8 @@ interface EventModalProps {
calendars: Calendar[]; calendars: Calendar[];
defaultDate?: Date; defaultDate?: Date;
defaultEndDate?: Date; defaultEndDate?: Date;
defaultAllDay?: boolean;
defaultCalendarId?: string;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>; onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => void; onDuplicate?: (data: Partial<CalendarEvent>) => void;
@@ -113,6 +118,8 @@ export function EventModal({
calendars, calendars,
defaultDate, defaultDate,
defaultEndDate, defaultEndDate,
defaultAllDay,
defaultCalendarId,
onSave, onSave,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -126,6 +133,7 @@ export function EventModal({
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event; const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit"); const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const userIsOrganizer = useMemo(() => { const userIsOrganizer = useMemo(() => {
@@ -197,9 +205,10 @@ export function EventModal({
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
const [allDay, setAllDay] = useState(event?.showWithoutTime || false); const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
const [calendarId, setCalendarId] = useState<string>(() => { const [calendarId, setCalendarId] = useState<string>(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
const defaultCal = calendars.find(c => c.isDefault); const defaultCal = calendars.find(c => c.isDefault);
return defaultCal?.id || calendars[0]?.id || ""; return defaultCal?.id || calendars[0]?.id || "";
}); });
@@ -235,6 +244,31 @@ export function EventModal({
const [sendInvitations, setSendInvitations] = useState(true); const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null); const participantInputRef = useRef<ParticipantInputHandle>(null);
// Plugin transform: collect conflict warnings for the current event form.
// Re-runs (debounced) whenever fields that affect scheduling change.
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
useEffect(() => {
let cancelled = false;
const t = setTimeout(async () => {
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const warnings = await calendarHooks.onCheckEventConflicts.transform([] as ConflictWarning[], {
event: {
title,
description,
start: startStr,
end: endStr,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
// Report live preview to parent for grid outline // Report live preview to parent for grid outline
useEffect(() => { useEffect(() => {
if (!onPreviewChange || isEdit) return; if (!onPreviewChange || isEdit) return;
@@ -490,14 +524,14 @@ export function EventModal({
return ( return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}> <div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<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>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 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 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3"> <div className="px-6 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3"> <div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" /> <CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
@@ -512,7 +546,7 @@ export function EventModal({
</div> </div>
<div className="text-sm"> <div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span> <span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && ( {!event.showWithoutTime && (
<span className="text-muted-foreground ml-2"> <span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)} {format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
@@ -545,48 +579,48 @@ export function EventModal({
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="px-6 py-4 border-t border-border flex-shrink-0"> <div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span> <span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"} variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")} onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted" className={userCurrentStatus === "accepted"
? "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 mr-1" />}
{t("participants.accepted")} {t("participants.accepted")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"} variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")} onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative" className={userCurrentStatus === "tentative"
? "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 mr-1" />}
{t("participants.tentative")} {t("participants.tentative")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"} variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")} onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined" className={userCurrentStatus === "declined"
? "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 mr-1" />}
{t("participants.declined")} {t("participants.declined")}
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }
@@ -633,7 +667,7 @@ export function EventModal({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(startD, "EEE, MMM d, yyyy")} {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 ml-1.5">{t("events.all_day")}</span>
@@ -762,16 +796,16 @@ export function EventModal({
return ( return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}> <div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")} {isEdit ? t("events.edit") : t("events.create")}
</h2> </h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 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 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label> <label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
@@ -916,6 +950,26 @@ export function EventModal({
)} )}
</div> </div>
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
<div
key={w.key}
className={
w.severity === 'error'
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
}
title={w.message}
>
{w.message}
</div>
))}
</div>
)}
{calendars.length > 1 && ( {calendars.length > 1 && (
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label> <label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
@@ -981,69 +1035,69 @@ export function EventModal({
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isEdit && onDelete && ( {isEdit && onDelete && (
showDeleteConfirm ? ( showDeleteConfirm ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<span className="text-sm text-red-600 dark:text-red-400"> <span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")} {t("form.delete_confirm")}
</span> </span>
{hasParticipants && ( {hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")} {t("participants.cancel_notification")}
</p> </p>
)} )}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div> </div>
) : (
<Button <Button
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => setShowDeleteConfirm(true)} onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400" className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
> >
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")} {t("events.delete")}
</Button> </Button>
) <Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
)} {t("form.cancel")}
{isEdit && onDuplicate && !showDeleteConfirm && ( </Button>
</div>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleDuplicate} onClick={() => setShowDeleteConfirm(true)}
aria-label={t("events.duplicate")} className="text-red-600 dark:text-red-400"
> >
<Copy className="w-4 h-4 mr-1" /> <Trash2 className="w-4 h-4 mr-1" />
{t("events.duplicate")} {t("events.delete")}
</Button> </Button>
)} )
</div> )}
{isEdit && onDuplicate && !showDeleteConfirm && (
<div className="flex gap-2"> <Button
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}> variant="ghost"
{t("form.cancel")} size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
</Button> </Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}> )}
{t("form.save")}
</Button>
</div>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
{t("form.save")}
</Button>
</div>
</div>
</div> </div>
); );
} }
+16 -9
View File
@@ -50,6 +50,7 @@ interface ContactFormProps {
contact?: ContactCard | null; contact?: ContactCard | null;
addressBooks?: AddressBook[]; addressBooks?: AddressBook[];
allKeywords?: string[]; allKeywords?: string[];
defaultAddressBookId?: string;
onSave: (data: Partial<ContactCard>) => Promise<void>; onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void; onCancel: () => void;
} }
@@ -143,11 +144,13 @@ function Select({ value, onChange, children, className }: {
); );
} }
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) { export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form"); const t = useTranslations("contacts.form");
const isEditing = !!contact; const isEditing = !!contact;
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || ""; // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input // Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
function anniversaryDateToString(date: AnniversaryDate): string { function anniversaryDateToString(date: AnniversaryDate): string {
@@ -210,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}; };
} }
const [prefix, setPrefix] = useState(findComponent("prefix")); const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given")); const [givenName, setGivenName] = useState(findComponent("given"));
const [additionalName, setAdditionalName] = useState(findComponent("additional")); const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname")); const [surname, setSurname] = useState(findComponent("surname"));
const [suffix, setSuffix] = useState(findComponent("suffix")); const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState( const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : "" contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
@@ -328,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
return ids[0]; return ids[0];
} }
} }
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
return defaultAddressBookId;
}
return ""; return "";
}, [contact]); }, [contact, defaultAddressBookId, addressBooks]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId); const [selectedBookId, setSelectedBookId] = useState(currentBookId);
const initialPhotoEntry = useMemo(() => { const initialPhotoEntry = useMemo(() => {
@@ -436,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
phonesMap[`p${i}`] = obj; phonesMap[`p${i}`] = obj;
}); });
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = []; const nameComponents = [];
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() }); if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() }); if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() }); if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
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" };
+74 -21
View File
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void; onRenameAddressBook?: (addressBook: AddressBook) => void;
onShareAddressBook?: (addressBook: AddressBook) => void;
onCreateContactInBook?: (addressBook: AddressBook) => void;
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void; onRenameKeyword?: (keyword: string) => void;
className?: string; className?: string;
} }
@@ -62,6 +65,9 @@ export function ContactsSidebar({
onDropContacts, onDropContacts,
onDropContactsToCategory, onDropContactsToCategory,
onRenameAddressBook, onRenameAddressBook,
onShareAddressBook,
onCreateContactInBook,
onDeleteAddressBook,
onRenameKeyword, onRenameKeyword,
className, className,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
@@ -288,7 +294,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined} onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
@@ -430,7 +436,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined} onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
@@ -438,24 +444,65 @@ export function ContactsSidebar({
</div> </div>
{/* Address book context menu */} {/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && ( {bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
<ContextMenu const book = bookContextMenu.data;
ref={bookMenuRef} const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
isOpen={bookContextMenu.isOpen} const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
position={bookContextMenu.position} const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
onClose={closeBookContextMenu} const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
> const showSeparator = (canCreate || canRename || canShare) && canDelete;
<ContextMenuItem return (
icon={Pencil} <ContextMenu
label={t("address_books.rename")} ref={bookMenuRef}
onClick={() => { isOpen={bookContextMenu.isOpen}
const book = bookContextMenu.data!; position={bookContextMenu.position}
closeBookContextMenu(); onClose={closeBookContextMenu}
onRenameAddressBook(book); >
}} {canCreate && (
/> <ContextMenuItem
</ContextMenu> icon={UserPlus}
)} label={t("address_books.new_contact_in_book")}
onClick={() => {
closeBookContextMenu();
onCreateContactInBook(book);
}}
/>
)}
{canRename && (
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={t("address_books.share")}
onClick={() => {
closeBookContextMenu();
onShareAddressBook(book);
}}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={t("address_books.delete")}
onClick={() => {
closeBookContextMenu();
onDeleteAddressBook(book);
}}
destructive
/>
)}
</ContextMenu>
);
})()}
{/* Keyword (category) context menu */} {/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && ( {keywordContextMenu.data && onRenameKeyword && (
@@ -638,7 +685,13 @@ 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>
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> {!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
)}>
{contactCount} {contactCount}
</span> </span>
</button> </button>
+139 -24
View File
@@ -10,6 +10,8 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
@@ -31,6 +33,7 @@ import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity"; import { findReplyIdentityId } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
@@ -67,6 +70,8 @@ interface EmailComposerProps {
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => void | Promise<void>; }) => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
@@ -87,6 +92,11 @@ interface EmailComposerProps {
receivedAt?: string; receivedAt?: string;
accountId?: string; accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>; attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
// Threading: parent's Message-ID and References, used to set RFC 5322
// In-Reply-To and References on outgoing replies. See #234.
messageId?: string;
inReplyTo?: string[];
references?: string[];
}; };
} }
@@ -116,6 +126,7 @@ export function EmailComposer({
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
@@ -317,6 +328,9 @@ export function EmailComposer({
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>` ? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: ''; : '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete); const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate); const addTemplate = useTemplateStore((s) => s.addTemplate);
const sendRawEmail = useEmailStore((s) => s.sendRawEmail); const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
const smimeStore = useSmimeStore(); const smimeStore = useSmimeStore();
@@ -437,10 +451,13 @@ export function EmailComposer({
return; return;
} }
autocompleteTimeoutRef.current = setTimeout(() => { autocompleteTimeoutRef.current = setTimeout(async () => {
const results = getAutocomplete(lastPart); const localResults = getAutocomplete(lastPart);
setAutocompleteResults(results); // Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
setActiveAutoField(results.length > 0 ? field : null); const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
setAutoSelectedIndex(-1); setAutoSelectedIndex(-1);
}, 200); }, 200);
}, [getAutocomplete]); }, [getAutocomplete]);
@@ -550,6 +567,19 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => { const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return; if (!client || files.length === 0) return;
// Let plugins veto each upload before it's queued.
const allowedFiles: File[] = [];
for (const file of files) {
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
});
if (ok) allowedFiles.push(file);
}
if (allowedFiles.length === 0) return;
files = allowedFiles;
const newAttachments: ComposerAttachment[] = files.map(file => { const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController(); const controller = new AbortController();
return { return {
@@ -578,6 +608,12 @@ export function EmailComposer({
: att : att
) )
); );
emailHooks.onAfterAttachmentUpload.emit({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
blobId,
});
} catch (error) { } catch (error) {
if (controller?.signal.aborted) continue; if (controller?.signal.aborted) continue;
debug.error(`Failed to upload ${file.name}:`, error); debug.error(`Failed to upload ${file.name}:`, error);
@@ -721,7 +757,7 @@ export function EmailComposer({
// Generate sub-addressed email if tag is set // Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email const fromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
@@ -736,7 +772,8 @@ export function EmailComposer({
fromEmail, fromEmail,
draftId || undefined, draftId || undefined,
uploadedAttachments, uploadedAttachments,
currentIdentity?.name || undefined currentIdentity?.name || undefined,
plainTextMode ? undefined : body
); );
setDraftId(savedDraftId); setDraftId(savedDraftId);
@@ -772,6 +809,19 @@ export function EmailComposer({
// Set new timeout for auto-save (2 seconds after last change) // Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = setTimeout(() => {
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
identityId: selectedIdentityId || '',
attachments: attachments
.filter(a => a.blobId && !a.uploading && !a.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
});
saveDraft(); saveDraft();
}, 2000); }, 2000);
@@ -812,19 +862,27 @@ export function EmailComposer({
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>; attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => { } => {
const known = inlineImagesRef.current; const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html'); const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>(); const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => { if (known.length > 0) {
const cid = img.getAttribute('data-cid'); doc.querySelectorAll('img[data-cid]').forEach((img) => {
if (!cid) return; const cid = img.getAttribute('data-cid');
const entry = known.find((e) => e.cid === cid); if (!cid) return;
if (!entry) return; const entry = known.find((e) => e.cid === cid);
img.setAttribute('src', `cid:${cid}`); if (!entry) return;
img.removeAttribute('data-cid'); img.setAttribute('src', `cid:${cid}`);
used.set(cid, entry); img.removeAttribute('data-cid');
used.set(cid, entry);
});
}
// Recipient mail clients apply default <p> margins inside table cells,
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
// to match the composer's tight rows.
doc.querySelectorAll('td > p, th > p').forEach((p) => {
const existing = p.getAttribute('style') || '';
p.setAttribute('style', `margin:0;${existing}`);
}); });
return { return {
@@ -889,7 +947,7 @@ export function EmailComposer({
const fromEmail = currentIdentity?.email const fromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
@@ -905,6 +963,11 @@ export function EmailComposer({
return ''; return '';
}; };
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo)
: null;
// In plain text mode, send text/plain only (no HTML body) // In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity) ? appendPlainTextSignature(body, currentIdentity)
@@ -968,12 +1031,22 @@ export function EmailComposer({
} }
// 4. Build canonical MIME // 4. Build canonical MIME
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
// references stays an array. threadingHeaders contains bare msg-ids.
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
? `<${threadingHeaders.inReplyTo[0]}>`
: undefined;
const mimeReferences = threadingHeaders?.references.length
? threadingHeaders.references.map(id => `<${id}>`)
: undefined;
const mimeBytes = buildMimeMessage({ const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })), to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined, bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
subject, subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
textBody: finalBody, textBody: finalBody,
htmlBody: finalHtmlBody, htmlBody: finalHtmlBody,
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
@@ -986,6 +1059,8 @@ export function EmailComposer({
to: toAddresses.map(e => ({ email: e })), to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
subject, subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
}; };
// 5. Sign if enabled // 5. Sign if enabled
@@ -1030,19 +1105,48 @@ export function EmailComposer({
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size })); .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments); uploadedAttachments.push(...inlineAttachments);
await onSend?.({ // Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses, to: toAddresses,
cc: ccAddresses, cc: ccAddresses,
bcc: bccAddresses, bcc: bccAddresses,
subject, subject,
body: finalBody, htmlBody: finalHtmlBody || '',
htmlBody: finalHtmlBody, textBody: finalBody,
identityId: currentIdentity?.id || '',
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
bcc: outgoing.bcc,
subject: outgoing.subject,
body: outgoing.textBody,
htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail, fromEmail,
fromName: currentIdentity?.name || undefined, fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id, identityId: outgoing.identityId || currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
}); });
if (mode === 'reply' || mode === 'replyAll') {
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, recipient).catch(err => {
debug.error('Failed to add trusted sender to address book:', err);
});
} else {
addTrustedSender(recipient);
}
}
}
} }
setTo(""); setTo("");
@@ -1100,8 +1204,14 @@ export function EmailComposer({
}; };
return ( return (
<div className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
/>
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div <div
className={cn("flex flex-col h-full bg-background relative", className)} className="flex flex-col h-full bg-background relative flex-1 min-w-0"
data-tour="composer" data-tour="composer"
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@@ -1173,7 +1283,7 @@ export function EmailComposer({
> >
{identities.map((identity) => { {identities.map((identity) => {
const displayEmail = subAddressTag const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag) ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email; : identity.email;
return ( return (
<option key={identity.id} value={identity.id}> <option key={identity.id} value={identity.id}>
@@ -1186,7 +1296,7 @@ export function EmailComposer({
<span className="text-sm text-foreground flex-1 truncate"> <span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? ( {subAddressTag ? (
<span className="font-mono"> <span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)} {generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
</span> </span>
) : ( ) : (
<> <>
@@ -1674,6 +1784,11 @@ export function EmailComposer({
</div> </div>
)} )}
</div> </div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
/>
</div>
); );
} }
+6 -4
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types'; import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing'; import { parseSubAddress } from '@/lib/sub-addressing';
import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps { interface EmailIdentityBadgeProps {
email: Email; email: Email;
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
className, className,
}: EmailIdentityBadgeProps) { }: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge'); const t = useTranslations('identities.badge');
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const fromAddress = email.from?.[0]?.email; const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null; if (!fromAddress) return null;
// Parse the from address to check for sub-addressing // Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress); const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user) // Find matching identity (email sent BY the user)
const matchingIdentity = identities.find( const matchingIdentity = identities.find(
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
if (!matchingIdentity) { if (!matchingIdentity) {
// Check all TO addresses for sub-address tags matching user's identities // Check all TO addresses for sub-address tags matching user's identities
for (const recipient of email.to || []) { for (const recipient of email.to || []) {
const parsedTo = parseSubAddress(recipient.email); const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
if (parsedTo.tag) { if (parsedTo.tag) {
// Check if this base email matches any of the user's identities // Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find( const matchingToIdentity = identities.find(
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
title={t('sub_address_tag', { tag: displayTag })} title={t('sub_address_tag', { tag: displayTag })}
> >
<Tag className="w-3 h-3" /> <Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span> <span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div> </div>
); );
} }
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
aria-label={t('sub_address_tag', { tag: displayTag })} aria-label={t('sub_address_tag', { tag: displayTag })}
> >
<Tag className="w-3 h-3" /> <Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span> <span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div> </div>
)} )}
+4 -1
View File
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore(); const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
@@ -164,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
email={sender?.email} email={sender?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/> />
)} )}
@@ -285,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div> </div>
{/* Third Line: Preview (controlled by showPreview setting) */} {/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && ( {showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn( <p className={cn(
"text-sm leading-relaxed line-clamp-2", "text-sm leading-relaxed line-clamp-2",
isUnread isUnread
+10 -2
View File
@@ -178,6 +178,14 @@ export function EmailList({
setIsProcessing(true); setIsProcessing(true);
try { try {
await batchDelete(client, isInTrash); await batchDelete(client, isInTrash);
const storeError = useEmailStore.getState().error;
if (storeError) {
const { toast } = await import('sonner');
toast.error(storeError);
}
} catch (err) {
const { toast } = await import('sonner');
toast.error(err instanceof Error ? err.message : 'Failed to delete emails');
} finally { } finally {
setTimeout(() => setIsProcessing(false), 500); setTimeout(() => setIsProcessing(false), 500);
} }
@@ -275,7 +283,7 @@ export function EmailList({
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between"> <div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300"> <div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected {t('batch_actions.selected_messages', { count: selectedEmailIds.size })}
</span> </span>
</div> </div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300"> <div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
@@ -330,7 +338,7 @@ export function EmailList({
disabled={isProcessing} disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50" className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
> >
Cancel {t('batch_actions.clear_selection')}
</Button> </Button>
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
+164 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback, useState, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
@@ -10,6 +10,10 @@ 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";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Bold, Bold,
@@ -29,6 +33,10 @@ import {
RemoveFormatting, RemoveFormatting,
Heading1, Heading1,
Heading2, Heading2,
Table as TableIcon,
Trash2,
Rows3,
Columns3,
} from "lucide-react"; } from "lucide-react";
export interface InlineImageUpload { export interface InlineImageUpload {
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5" />; return <div className="w-px h-5 bg-border mx-0.5" />;
} }
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
<div>
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
const r = Math.floor(i / TABLE_PICKER_COLS);
const c = i % TABLE_PICKER_COLS;
const active = hover && r <= hover.r && c <= hover.c;
return (
<button
key={i}
type="button"
onMouseEnter={() => setHover({ r, c })}
onClick={() => onPick(r + 1, c + 1)}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
)}
/>
);
})}
</div>
<div className="text-xs text-muted-foreground mt-1.5 text-center">
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
</div>
</div>
);
}
export function RichTextEditor({ export function RichTextEditor({
content, content,
onChange, onChange,
@@ -109,6 +154,27 @@ export function RichTextEditor({
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
}), }),
Table.configure({
resizable: true,
HTMLAttributes: {
border: "1",
cellpadding: "6",
cellspacing: "0",
width: "100%",
style: "width:100%;border-collapse:collapse;",
},
}),
TableRow,
TableHeader.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
},
}),
TableCell.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
},
}),
], ],
content, content,
editorProps: { editorProps: {
@@ -188,6 +254,20 @@ export function RichTextEditor({
.run(); .run();
}, [editor]); }, [editor]);
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!tableMenuOpen) return;
const handler = (e: MouseEvent) => {
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
setTableMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [tableMenuOpen]);
if (!editor) { if (!editor) {
return ( return (
<div className={cn("min-h-[100px]", className)} /> <div className={cn("min-h-[100px]", className)} />
@@ -309,6 +389,89 @@ export function RichTextEditor({
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</ToolbarButton> </ToolbarButton>
<div ref={tableWrapperRef} className="relative">
<ToolbarButton
active={editor.isActive("table")}
onClick={() => setTableMenuOpen((v) => !v)}
title="Table"
>
<TableIcon className="w-4 h-4" />
</ToolbarButton>
{tableMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
{editor.isActive("table") ? (
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row above
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row below
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column before
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column after
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete row
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete column
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Toggle header row
</button>
<div className="h-px bg-border my-1" />
<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"
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete table
</button>
</div>
) : (
<TableSizePicker
onPick={(rows, cols) => {
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
setTableMenuOpen(false);
}}
/>
)}
</div>
)}
</div>
<ToolbarSeparator /> <ToolbarSeparator />
<ToolbarButton <ToolbarButton
+8 -2
View File
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView); const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById); const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
@@ -182,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
email={sender?.email} email={sender?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/> />
)} )}
@@ -306,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{email.subject || "(no subject)"} {email.subject || "(no subject)"}
</div> </div>
{showPreview && density !== 'extra-compact' && ( {showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn( <p className={cn(
"text-sm leading-relaxed line-clamp-2", "text-sm leading-relaxed line-clamp-2",
isUnread isUnread
@@ -359,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
@@ -376,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)).slice(0, 4) )).slice(0, 4)
: participantNames; : participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0]; const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({ const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail, email: latestEmail,
@@ -563,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={avatarPerson?.email} email={avatarPerson?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/> />
)} )}
@@ -709,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{latestEmail.subject || "(no subject)"} {latestEmail.subject || "(no subject)"}
</div> </div>
{showPreview && density !== 'extra-compact' && ( {showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn( <p className={cn(
"text-sm leading-relaxed line-clamp-2", "text-sm leading-relaxed line-clamp-2",
hasUnread hasUnread
+4 -2
View File
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import { import {
generateSubAddress, generateSubAddress,
extractDomain, extractDomain,
@@ -35,6 +36,7 @@ export function SubAddressHelper({
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore(); const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
// Get suggestions based on recipient (memoized for performance) // Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
@@ -47,7 +49,7 @@ export function SubAddressHelper({
}, [recipientEmails]); }, [recipientEmails]);
// Generate preview // Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail; const preview = tag ? generateSubAddress(baseEmail, tag, subAddressDelimiter) : baseEmail;
// Close popover when clicking outside // Close popover when clicking outside
useEffect(() => { useEffect(() => {
@@ -226,7 +228,7 @@ export function SubAddressHelper({
{/* Help Text */} {/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground"> <div className="mb-3 text-xs text-muted-foreground">
{t('help_text')} {t('help_text', { delimiter: subAddressDelimiter })}
</div> </div>
{/* Use Address Button */} {/* Use Address Button */}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import { getMailboxPath } from "@/lib/utils";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
CheckCheck,
ChevronRight,
MailOpen,
Mails,
MoreHorizontal,
Trash2,
FolderPlus,
Pencil,
FolderX,
RefreshCw,
} from "lucide-react";
interface Position {
x: number;
y: number;
}
export type MailboxContextTarget =
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
| { kind: "folders-section" };
const PATH_SEPARATOR = " ";
const MAX_PATH_LENGTH = 40;
const MAX_SEGMENT_LENGTH = 16;
function truncateSegment(name: string): string {
return name.length > MAX_SEGMENT_LENGTH
? `${name.slice(0, MAX_SEGMENT_LENGTH - 1)}`
: name;
}
function renderPathSegments(segments: string[]): React.ReactNode {
return (
<span className="inline-flex items-center gap-1 align-middle">
{segments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3.5 h-3.5 opacity-60" />}
{seg === "…" ? <MoreHorizontal className="w-3.5 h-3.5" /> : <span>{seg}</span>}
</span>
))}
</span>
);
}
function renderShortenedPath(fullPath: string): React.ReactNode {
const segments = fullPath.split(PATH_SEPARATOR);
if (fullPath.length <= MAX_PATH_LENGTH) {
return renderPathSegments(segments);
}
if (segments.length <= 2) {
return renderPathSegments(segments.map(truncateSegment));
}
return renderPathSegments([
truncateSegment(segments[0]),
"…",
truncateSegment(segments[segments.length - 1]),
]);
}
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefresh?: () => void;
}
export function MailboxContextMenu({
target,
position,
isOpen,
onClose,
menuRef,
mailboxes,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const handleAction = (action: () => void) => {
action();
onClose();
};
if (!target) return null;
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(onCreateFolder!)}
disabled={!onCreateFolder}
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
const mailbox = target.mailbox;
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const fullPath = getMailboxPath(mailbox, mailboxes);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
</ContextMenuHeader>
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
destructive
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
+34 -9
View File
@@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link, useRouter } from "@/i18n/navigation"; import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -17,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
@@ -176,6 +176,11 @@ export function NavigationRail({
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
// Account list for rail // Account list for rail
const accounts = useAccountStore((s) => s.accounts); const accounts = useAccountStore((s) => s.accounts);
@@ -336,9 +341,9 @@ export function NavigationRail({
); );
})} })}
{/* Admin (Stalwart admins) */} {/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && ( {isStalwartAdmin && (
<NextLink <a
href="/admin" href="/admin"
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
@@ -346,9 +351,20 @@ export function NavigationRail({
"text-muted-foreground hover:text-foreground" "text-muted-foreground hover:text-foreground"
)} )}
> >
<Shield className="w-5 h-5" /> <span className="relative">
<Shield className="w-5 h-5" />
{hasUpdate && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink> </a>
)} )}
{/* Settings */} {/* Settings */}
@@ -401,7 +417,7 @@ export function NavigationRail({
<nav <nav
className={cn( className={cn(
"flex flex-col", "flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2", collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
)} )}
role="navigation" role="navigation"
@@ -514,13 +530,22 @@ export function NavigationRail({
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */} {/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1"> <div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && ( {isStalwartAdmin && (
<NextLink <a
href="/admin" href="/admin"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted" className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
title={t("admin") || "Admin"} title={t("admin") || "Admin"}
> >
<Shield className="w-[18px] h-[18px]" /> <Shield className="w-[18px] h-[18px]" />
</NextLink> {hasUpdate && (
<span
className={cn(
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</a>
)} )}
<Link <Link
+65 -1
View File
@@ -31,6 +31,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store'; import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types'; import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types'; import type { UnifiedMailboxRole } from '@/lib/jmap/types';
@@ -56,6 +58,15 @@ interface SidebarProps {
onCompose?: () => void; onCompose?: () => void;
onSidebarClose?: () => void; onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void; onUnreadFilterClick?: (mailboxId: string) => void;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
className?: string; className?: string;
} }
@@ -187,6 +198,7 @@ interface SidebarRowProps {
dropHandlers?: Record<string, unknown>; dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean; isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean; isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
} }
function SidebarRow({ function SidebarRow({
@@ -206,6 +218,7 @@ function SidebarRow({
dropHandlers, dropHandlers,
isValidDropTarget, isValidDropTarget,
isInvalidDropTarget, isInvalidDropTarget,
onContextMenu,
}: SidebarRowProps) { }: SidebarRowProps) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -213,6 +226,7 @@ function SidebarRow({
return ( return (
<div <div
{...(dropHandlers || {})} {...(dropHandlers || {})}
onContextMenu={onContextMenu}
style={{ paddingBlock: 'var(--density-sidebar-py)' }} style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn( className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150", "group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -365,6 +379,7 @@ function MailboxTreeItem({
isCollapsed, isCollapsed,
onUnreadFilterClick, onUnreadFilterClick,
colorful, colorful,
onContextMenu,
}: { }: {
node: MailboxNode; node: MailboxNode;
selectedMailbox: string; selectedMailbox: string;
@@ -374,6 +389,7 @@ function MailboxTreeItem({
isCollapsed: boolean; isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void; onUnreadFilterClick?: (mailboxId: string) => void;
colorful: boolean; colorful: boolean;
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) { }) {
const tNotifications = useTranslations('notifications'); const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
@@ -423,6 +439,7 @@ function MailboxTreeItem({
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined} dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget} isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget} isInvalidDropTarget={isInvalidDropTarget}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
/> />
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
@@ -436,6 +453,7 @@ function MailboxTreeItem({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick} onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful} colorful={colorful}
onContextMenu={onContextMenu}
/> />
))} ))}
</> </>
@@ -610,6 +628,15 @@ export function Sidebar({
onCompose: _onCompose, onCompose: _onCompose,
onSidebarClose, onSidebarClose,
onUnreadFilterClick, onUnreadFilterClick,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefreshMailboxes,
className, className,
}: SidebarProps) { }: SidebarProps) {
const router = useRouter(); const router = useRouter();
@@ -790,6 +817,23 @@ export function Sidebar({
router.push('/settings'); router.push('/settings');
}; };
const {
contextMenu: mailboxContextMenu,
openContextMenu: openMailboxContextMenu,
closeContextMenu: closeMailboxContextMenu,
menuRef: mailboxMenuRef,
} = useContextMenu<MailboxContextTarget>();
const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => {
const mailbox = mailboxes.find(mb => mb.id === node.id);
if (!mailbox) return;
openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 });
};
const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => {
openMailboxContextMenu(e, { kind: "folders-section" });
};
return ( return (
<div <div
className={cn( className={cn(
@@ -866,7 +910,7 @@ export function Sidebar({
</div> </div>
)} )}
<div> <div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader <SidebarSectionHeader
label={t("folders")} label={t("folders")}
expanded={foldersExpanded} expanded={foldersExpanded}
@@ -894,6 +938,7 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick} onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons} colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/> />
)) ))
)} )}
@@ -934,6 +979,7 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick} onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons} colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/> />
))} ))}
</div> </div>
@@ -975,6 +1021,24 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />} {!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div> </div>
<MailboxContextMenu
target={mailboxContextMenu.data}
position={mailboxContextMenu.position}
isOpen={mailboxContextMenu.isOpen}
onClose={closeMailboxContextMenu}
menuRef={mailboxMenuRef}
mailboxes={mailboxes}
onMarkFolderRead={onMarkFolderRead}
onMarkFolderTreeRead={onMarkFolderTreeRead}
onMarkAllFoldersRead={onMarkAllFoldersRead}
onEmptyFolder={onEmptyFolder}
onCreateSubfolder={onCreateSubfolder}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onRefresh={onRefreshMailboxes}
/>
</div> </div>
); );
} }
+4
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store'; import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json'; import jaMessages from '@/locales/ja/common.json';
@@ -15,11 +16,13 @@ import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json'; import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json'; import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json'; import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
cs: csMessages,
en: enMessages, en: enMessages,
fr: frMessages, fr: frMessages,
ja: jaMessages, ja: jaMessages,
@@ -32,6 +35,7 @@ const ALL_MESSAGES = {
pl: plMessages, pl: plMessages,
pt: ptMessages, pt: ptMessages,
ru: ruMessages, ru: ruMessages,
tr: trMessages,
uk: ukMessages, uk: ukMessages,
zh: zhMessages, zh: zhMessages,
}; };
+3 -1
View File
@@ -2,6 +2,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
export function ServiceWorkerRegistration() { export function ServiceWorkerRegistration() {
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) { if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
@@ -23,7 +25,7 @@ export function ServiceWorkerRegistration() {
} }
navigator.serviceWorker navigator.serviceWorker
.register("/sw.js") .register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
.then((registration) => { .then((registration) => {
console.log("Service Worker registered successfully:", registration); console.log("Service Worker registered successfully:", registration);
}) })
+35 -1
View File
@@ -1,18 +1,51 @@
"use client"; "use client";
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SpamSiegeGame } from './spam-siege-game'; import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
function VersionUpdateTag() {
const status = useUpdateStore((s) => s.status);
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => {
startPolling();
}, [startPolling]);
if (!status?.updateAvailable) return null;
if (status.severity === 'unknown' || status.severity === 'none') return null;
const important = status.severity === 'security' || status.severity === 'deprecated';
const label =
status.severity === 'security' ? 'security'
: status.severity === 'deprecated' ? 'deprecated'
: status.latest ?? 'update';
return (
<span
className={cn(
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
important
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
>
{important ? label : `update: ${label}`}
</span>
);
}
export function AboutDataSettings() { export function AboutDataSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
</p> </p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform"> <p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span> v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
<VersionUpdateTag />
</p> </p>
</div> </div>
</button> </button>
@@ -2,13 +2,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react"; import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types"; import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
function AddressBookEditRow({ function AddressBookEditRow({
initial, initial,
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts"); const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts"); const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore(); const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore(); const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null); const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -137,6 +139,19 @@ export function AddressBookManagementSettings() {
{t("default")} {t("default")}
</span> </span>
)} )}
{(() => {
const shareCount = Object.keys(book.shareWith || {}).length;
if (shareCount === 0 || book.isShared) return null;
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t("share")}
>
<Users className="w-3 h-3" />
{shareCount}
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && ( {canRename && (
<button <button
@@ -148,6 +163,16 @@ export function AddressBookManagementSettings() {
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
)} )}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"
onClick={() => setSharingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("share")}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
</div> </div>
); );
@@ -242,6 +267,24 @@ export function AddressBookManagementSettings() {
</div> </div>
</SettingsSection> </SettingsSection>
</div> </div>
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</> </>
); );
} }
+5 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const tAdvanced = useTranslations('settings.advanced'); const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour(); const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -130,6 +130,10 @@ export function AppearanceSettings() {
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} /> <ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem> </SettingItem>
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
</SettingItem>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}> <SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button <Button
variant="outline" variant="outline"
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section'; import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react'; import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime } from '@/lib/utils'; import { cn, formatDateTime } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal'; import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
@@ -83,7 +85,7 @@ function CalendarColorPicker({
); );
} }
function CalendarEditForm({ export function CalendarEditForm({
initial, initial,
onSave, onSave,
onCancel, onCancel,
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() { export function CalendarManagementSettings() {
const t = useTranslations('calendar.management'); const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore(); const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({}); const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null); const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
const [clearingId, setClearingId] = useState<string | null>(null); const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null); const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null); const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
@@ -513,6 +516,20 @@ export function CalendarManagementSettings() {
</span> </span>
)} )}
{(() => {
const shareCount = Object.keys(cal.shareWith || {}).length;
if (shareCount === 0 || cal.isShared) return null;
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t('share')}
>
<Users className="w-3 h-3" />
{shareCount}
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
type="button" type="button"
@@ -522,6 +539,16 @@ export function CalendarManagementSettings() {
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
onClick={() => setSharingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('share')}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => setClearingId(cal.id)} onClick={() => setClearingId(cal.id)}
@@ -689,6 +716,24 @@ export function CalendarManagementSettings() {
onClose={() => setEditingSubscription(null)} onClose={() => setEditingSubscription(null)}
/> />
)} )}
{sharingId && client && (() => {
const cal = calendars.find((c) => c.id === sharingId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</SettingsSection> </SettingsSection>
); );
} }
+55 -2
View File
@@ -4,8 +4,17 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react'; import { Mail, X } from 'lucide-react';
import { getPathPrefix } from '@/lib/browser-navigation';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
isValidSubAddressDelimiter,
} from '@/lib/sub-addressing';
const CUSTOM_DELIMITER_SENTINEL = '__custom__';
const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() { export function ComposingSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
@@ -17,13 +26,14 @@ export function ComposingSettings() {
autoSelectReplyIdentity, autoSelectReplyIdentity,
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
subAddressDelimiter,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const handleSetDefaultMailProgram = useCallback(() => { const handleSetDefaultMailProgram = useCallback(() => {
try { try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) { if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`); navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success'); setDefaultMailStatus('success');
} }
} catch { } catch {
@@ -40,6 +50,49 @@ export function ComposingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
>
<div className="flex flex-col items-end gap-2">
<Select
value={isSupportedSubAddressDelimiter(subAddressDelimiter) ? subAddressDelimiter : CUSTOM_DELIMITER_SENTINEL}
onChange={(value) => {
if (value === CUSTOM_DELIMITER_SENTINEL) {
if (isSupportedSubAddressDelimiter(subAddressDelimiter)) {
updateSetting('subAddressDelimiter', DEFAULT_CUSTOM_DELIMITER);
}
} else {
updateSetting('subAddressDelimiter', value);
}
}}
options={[
...SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({
value: delim,
label: t('sub_address_delimiter.option', { delimiter: delim }),
})),
{ value: CUSTOM_DELIMITER_SENTINEL, label: t('sub_address_delimiter.custom') },
]}
/>
{!isSupportedSubAddressDelimiter(subAddressDelimiter) && (
<input
type="text"
maxLength={1}
value={subAddressDelimiter}
onChange={(e) => {
const next = e.target.value.slice(0, 1);
if (next && isValidSubAddressDelimiter(next)) {
updateSetting('subAddressDelimiter', next);
}
}}
aria-label={t('sub_address_delimiter.custom_input_label')}
placeholder={DEFAULT_CUSTOM_DELIMITER}
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}> <SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch <ToggleSwitch
checked={attachmentReminderEnabled} checked={attachmentReminderEnabled}
+170 -1
View File
@@ -1,13 +1,32 @@
"use client"; "use client";
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react'; import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import {
DEFAULT_RELAY_BASE_URL,
WebPushUnsupportedError,
disableWebPush,
enableWebPush,
isWebPushEnabled,
isWebPushSupported,
} from '@/lib/web-push';
type PushStatus =
| { kind: 'idle' }
| { kind: 'busy' }
| { kind: 'enabled' }
| { kind: 'unsupported' }
| { kind: 'error'; message: string };
export function NotificationSettings() { export function NotificationSettings() {
const t = useTranslations('settings.notifications'); const t = useTranslations('settings.notifications');
@@ -21,6 +40,77 @@ export function NotificationSettings() {
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
useEffect(() => {
if (!supported) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
})();
}, [supported]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
const busy = pushStatus.kind === 'busy';
const handleEnablePush = async () => {
if (!client) {
setPushStatus({ kind: 'error', message: 'Sign in first' });
return;
}
if (!isValidRelay) {
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
return;
}
setPushStatus({ kind: 'busy' });
try {
await enableWebPush({
client,
relayBaseUrl: trimmedRelay,
accountLabel: username ?? undefined,
});
setPushStatus({ kind: 'enabled' });
} catch (err) {
if (err instanceof WebPushUnsupportedError) {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to enable push',
});
}
};
const handleDisablePush = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: t('push.confirm_disable_title'),
message: t('push.confirm_disable_message'),
confirmText: t('push.disable'),
variant: 'destructive',
});
if (!confirmed) return;
setPushStatus({ kind: 'busy' });
try {
await disableWebPush({ client, relayBaseUrl: trimmedRelay });
setPushStatus({ kind: 'idle' });
} catch (err) {
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to disable push',
});
}
};
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({ const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id, value: s.id,
@@ -29,6 +119,46 @@ export function NotificationSettings() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium" htmlFor="push-relay-url">
{t('push.relay_label')}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<input
id="push-relay-url"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleEnablePush}
disabled={busy || pushStatus.kind === 'unsupported' || !isValidRelay || !client}
>
{pushStatus.kind === 'enabled' ? t('push.reenable') : t('push.enable')}
</Button>
{pushStatus.kind === 'enabled' && (
<Button variant="outline" onClick={handleDisablePush} disabled={busy}>
{t('push.disable')}
</Button>
)}
</div>
{pushStatus.kind === 'unsupported' && (
<p className="text-xs text-muted-foreground">{t('push.ios_hint')}</p>
)}
</div>
</SettingsSection>
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}> <SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
<SettingItem <SettingItem
label={t('sound_selection.choose')} label={t('sound_selection.choose')}
@@ -118,6 +248,45 @@ export function NotificationSettings() {
/> />
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
); );
} }
function PushStatusBadge({
status,
t,
}: {
status: PushStatus;
t: ReturnType<typeof useTranslations>;
}) {
if (status.kind === 'enabled') {
return (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{t('push.status_active')}
</span>
);
}
if (status.kind === 'busy') {
return <span className="text-xs text-muted-foreground">{t('push.status_busy')}</span>;
}
if (status.kind === 'unsupported') {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<XCircle className="w-3.5 h-3.5" />
{t('push.status_unsupported')}
</span>
);
}
if (status.kind === 'error') {
return (
<span className="inline-flex items-center gap-1 text-xs text-destructive" title={status.message}>
<XCircle className="w-3.5 h-3.5" />
{status.message}
</span>
);
}
return <span className="text-xs text-muted-foreground">{t('push.status_inactive')}</span>;
}
+8
View File
@@ -34,6 +34,7 @@ export function ReadingSettings() {
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
hideInlineImageAttachments, hideInlineImageAttachments,
attachmentImagePreviewsEnabled,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -206,6 +207,13 @@ export function ReadingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('attachment_image_previews.label')} description={t('attachment_image_previews.description')}>
<ToggleSwitch
checked={attachmentImagePreviewsEnabled}
onChange={(checked) => updateSetting('attachmentImagePreviewsEnabled', checked)}
/>
</SettingItem>
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <div className="py-3 border-b border-border space-y-3">
<div> <div>
+2 -2
View File
@@ -44,8 +44,8 @@ interface SettingItemProps {
export function SettingItem({ label, description, children, locked }: SettingItemProps) { export function SettingItem({ label, description, children, locked }: SettingItemProps) {
return ( return (
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}> <div className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}>
<div className="flex-1 pr-4"> <div className="flex-1 min-w-0 sm:pr-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label> <label className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />} {locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
@@ -0,0 +1,350 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
return name;
}
}
return "custom";
}
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
collectionName: string;
shareWith: Record<string, AnyRights> | null | undefined;
ownAccountId: string;
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
onClose: () => void;
}
export function ShareCollectionDialog({
client,
kind,
collectionName,
shareWith,
ownAccountId,
onShare,
onClose,
}: ShareCollectionDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
// Load principals on mount
useEffect(() => {
let cancelled = false;
setLoadingPrincipals(true);
client.getPrincipals().then((list) => {
if (cancelled) return;
setAllPrincipals(list);
setLoadingPrincipals(false);
}).catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => { cancelled = true; };
}, [client]);
// Map of every fetched principal by id, used for name/description lookups in
// the shared list. Must include principals that already have a share so the
// list shows their name rather than the raw id.
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of allPrincipals) map.set(p.id, p);
return map;
}, [allPrincipals]);
// Principals available to add: exclude self and anyone already shared with.
const principals = useMemo(() => {
const existing = new Set(Object.keys(shareWith || {}));
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
}, [allPrincipals, ownAccountId, shareWith]);
// Close on Escape, focus trap, click outside
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
if (!rights) return;
setSavingId(principalId);
try {
await onShare(principalId, rights);
toast.success(t("share_updated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleRemove = async (principalId: string) => {
setSavingId(principalId);
try {
await onShare(principalId, null);
toast.success(t("share_removed"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleAdd = async (principal: Principal) => {
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
setShowAdd(false);
setSearch("");
toast.success(t("share_added"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const filteredPrincipals = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return principals;
return principals.filter((p) =>
p.name.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q)
);
}, [principals, search]);
const sharedEntries = useMemo(() => {
return Object.entries(shareWith || {}) as [string, AnyRights][];
}, [shareWith]);
const presetOptions = kind === "calendar"
? ["freeBusy", "read", "readWrite", "manager"] as const
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title", { name: collectionName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{sharedEntries.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
{t("no_shares")}
</div>
)}
{sharedEntries.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{principal?.name || principal?.email || principalId}
</div>
{principal?.description && (
<div className="text-xs text-muted-foreground truncate">
{principal.description}
</div>
)}
</div>
<div className="relative">
<select
value={preset}
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
disabled={savingId === principalId}
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
))}
{preset === "custom" && (
<option value="custom">{t("preset.custom")}</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
<button
onClick={() => handleRemove(principalId)}
disabled={savingId === principalId}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={t("remove")}
title={t("remove")}
>
{savingId === principalId
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />}
</button>
</li>
);
})}
</ul>
)}
{!showAdd && (
<Button
variant="outline"
onClick={() => setShowAdd(true)}
className="w-full"
>
<UserPlus className="w-4 h-4 mr-2" />
{t("add_person")}
</Button>
)}
{showAdd && (
<div className="space-y-2 border border-border rounded-md p-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("search_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
<div className="max-h-48 overflow-y-auto -mx-1">
{loadingPrincipals && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("loading_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
{search.trim() ? t("no_match") : t("no_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.map((p) => (
<button
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2">
{p.name}
{p.type === "group" && (
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
{t("group")}
</span>
)}
</div>
{p.email && p.email !== p.name && (
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
)}
</div>
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
</div>
</button>
))}
</div>
<div className="flex justify-end pt-1">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
{tCommon("cancel")}
</Button>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
);
}
+16 -5
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"; import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import { useRouter } from "@/i18n/navigation"; import { useRouter, usePathname } from "@/i18n/navigation";
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 { useWebDAVStore } from "@/stores/webdav-store"; import { useWebDAVStore } from "@/stores/webdav-store";
@@ -34,6 +34,7 @@ export function useTour() {
export function TourProvider({ children }: { children: ReactNode }) { export function TourProvider({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { isDemoMode } = useAuthStore(); const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore(); const { supportsWebDAV } = useWebDAVStore();
@@ -63,9 +64,16 @@ export function TourProvider({ children }: { children: ReactNode }) {
// If the resume step is beyond the current steps, start from 0 // If the resume step is beyond the current steps, start from 0
if (resumeStep >= steps.length) resumeStep = 0; if (resumeStep >= steps.length) resumeStep = 0;
// Most steps live on the mailbox — navigate there (or to the step's specific page)
// so we don't start the tour on a page where the targets don't exist.
const targetPage = steps[resumeStep]?.page ?? "/";
if (pathname !== targetPage) {
router.push(targetPage);
}
setCurrentStep(resumeStep); setCurrentStep(resumeStep);
setIsActive(true); setIsActive(true);
}, [steps.length]); }, [steps, pathname, router]);
const stopTour = useCallback(() => { const stopTour = useCallback(() => {
setIsActive(false); setIsActive(false);
@@ -90,14 +98,17 @@ export function TourProvider({ children }: { children: ReactNode }) {
} }
const next = currentStep + 1; const next = currentStep + 1;
const nextStepDef = steps[next]; const nextStepDef = steps[next];
const currentStepDef = steps[currentStep];
setCurrentStep(next); setCurrentStep(next);
try { try {
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next)); localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
} catch { /* */ } } catch { /* */ }
// Navigate if the next step requires a different page // Navigate when the next step is on a different page (treat "no page" as the mailbox)
if (nextStepDef?.page) { const nextPage = nextStepDef?.page ?? "/";
router.push(nextStepDef.page); const currentPage = currentStepDef?.page ?? "/";
if (nextPage !== currentPage) {
router.push(nextPage);
} }
}, [currentStep, steps, completeTour, router]); }, [currentStep, steps, completeTour, router]);
+8 -4
View File
@@ -140,9 +140,11 @@ interface AvatarProps {
contactPhotoUri?: string; contactPhotoUri?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
className?: string; className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean;
} }
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null); const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -222,9 +224,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed const imgSrc = disableImages
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) ? null
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null); : !imgError && !domainFailed
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => { const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source // If the plugin avatar just failed, mark it and fall through to the next source
+90 -21
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { forwardRef, useState, useRef, useEffect } from "react"; import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
@@ -17,26 +17,72 @@ interface ContextMenuProps {
children: React.ReactNode; children: React.ReactNode;
} }
const VIEWPORT_MARGIN = 10;
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>( export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose: _onClose, children }, ref) => { ({ isOpen, position, onClose: _onClose, children }, ref) => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
const localRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// Measure the rendered menu and clamp it inside the viewport before the
// browser paints. We hide the element until this runs so the user never
// sees the menu jump from an unclamped position to a clamped one.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
return;
}
const node = localRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = position.x;
let y = position.y;
if (x + rect.width > vw - VIEWPORT_MARGIN) {
x = vw - rect.width - VIEWPORT_MARGIN;
}
if (y + rect.height > vh - VIEWPORT_MARGIN) {
y = vh - rect.height - VIEWPORT_MARGIN;
}
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition({ x, y });
}, [isOpen, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
};
if (!mounted || !isOpen) return null; if (!mounted || !isOpen) return null;
const renderPosition = adjustedPosition ?? position;
const isPositioned = adjustedPosition !== null;
return createPortal( return createPortal(
<div <div
ref={ref} ref={setRefs}
className={cn( className={cn(
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border", "fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border"
"animate-in fade-in-0 zoom-in-95 duration-100"
)} )}
style={{ style={{
left: position.x, left: renderPosition.x,
top: position.y, top: renderPosition.y,
visibility: isPositioned ? "visible" : "hidden",
}} }}
role="menu" role="menu"
aria-orientation="vertical" aria-orientation="vertical"
@@ -112,22 +158,41 @@ export function ContextMenuSubMenu({
children, children,
}: ContextMenuSubMenuProps) { }: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right"); const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
const itemRef = useRef<HTMLDivElement>(null); const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null); const subMenuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useLayoutEffect(() => {
if (isOpen && itemRef.current) { if (!isOpen) {
const rect = itemRef.current.getBoundingClientRect(); setSubMenuPos(null);
const viewportWidth = window.innerWidth; return;
if (rect.right + 200 > viewportWidth - 10) {
setSubMenuPosition("left");
} else {
setSubMenuPosition("right");
}
} }
const itemEl = itemRef.current;
const subEl = subMenuRef.current;
if (!itemEl || !subEl) return;
const itemRect = itemEl.getBoundingClientRect();
const subRect = subEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left: number;
if (itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN) {
left = itemRect.right;
} else if (itemRect.left - subRect.width >= VIEWPORT_MARGIN) {
left = itemRect.left - subRect.width;
} else {
left = Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
}
let top = itemRect.top;
if (top + subRect.height > vh - VIEWPORT_MARGIN) {
top = vh - subRect.height - VIEWPORT_MARGIN;
}
top = Math.max(VIEWPORT_MARGIN, top);
setSubMenuPos({ x: left, y: top });
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
@@ -174,13 +239,17 @@ export function ContextMenuSubMenu({
<div <div
ref={subMenuRef} ref={subMenuRef}
className={cn( className={cn(
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border", "fixed z-50 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100", "animate-in fade-in-0 zoom-in-95 duration-100"
subMenuPosition === "right" ? "left-full" : "right-full"
)} )}
style={{
left: subMenuPos?.x ?? 0,
top: subMenuPos?.y ?? 0,
visibility: subMenuPos ? "visible" : "hidden",
}}
role="menu" role="menu"
> >
<div className="py-1 max-h-[300px] overflow-y-auto"> <div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
{children} {children}
</div> </div>
</div> </div>
+29 -3
View File
@@ -45,9 +45,22 @@ export function FlagKR(props: FlagProps) {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" /> <rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" /> <path d="M1.5 0.5 a0.45 0.45 0 1 1 0 0.9 a0.45 0.45 0 1 0 0 -0.9" fill="#CD2E3A" />
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" /> <path d="M1.5 1.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" fill="#0047A0" />
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" /> <circle cx="1.5" cy="0.8" r="0.225" fill="#0047A0" />
<circle cx="1.5" cy="1.2" r="0.225" fill="#CD2E3A" />
<g stroke="#000" strokeWidth="0.06" strokeLinecap="round">
<line x1="0.42" y1="0.35" x2="0.78" y2="0.35" />
<line x1="0.42" y1="0.46" x2="0.78" y2="0.46" />
<line x1="0.42" y1="0.57" x2="0.78" y2="0.57" />
<line x1="2.22" y1="0.35" x2="2.58" y2="0.35" />
<line x1="2.22" y1="0.57" x2="2.58" y2="0.57" />
<line x1="0.42" y1="1.43" x2="0.78" y2="1.43" />
<line x1="0.42" y1="1.65" x2="0.78" y2="1.65" />
<line x1="2.22" y1="1.43" x2="2.58" y2="1.43" />
<line x1="2.22" y1="1.54" x2="2.58" y2="1.54" />
<line x1="2.22" y1="1.65" x2="2.58" y2="1.65" />
</g>
</svg> </svg>
); );
} }
@@ -139,6 +152,18 @@ export function FlagRU(props: FlagProps) {
); );
} }
/** Turkey - Red with white crescent and star */
export function FlagTR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
<rect width="30" height="20" fill="#E30A17" />
<circle cx="10" cy="10" r="6" fill="#fff" />
<circle cx="11.5" cy="10" r="5" fill="#E30A17" />
<polygon points="19.5,7.8 19.994,9.32 21.592,9.32 20.299,10.26 20.793,11.78 19.5,10.84 18.207,11.78 18.701,10.26 17.408,9.32 19.006,9.32" fill="#fff" />
</svg>
);
}
/** Ukraine Blue, Yellow horizontal */ /** Ukraine Blue, Yellow horizontal */
export function FlagUA(props: FlagProps) { export function FlagUA(props: FlagProps) {
return ( return (
@@ -179,6 +204,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
pl: FlagPL, pl: FlagPL,
pt: FlagBR, pt: FlagBR,
ru: FlagRU, ru: FlagRU,
tr: FlagTR,
uk: FlagUA, uk: FlagUA,
zh: FlagCN, zh: FlagCN,
}; };
+2
View File
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons'; import { flagComponents } from './flag-icons';
const languages = [ const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' }, { value: 'ja', label: '日本語' },
@@ -20,6 +21,7 @@ const languages = [
{ value: 'pl', label: 'Polski' }, { value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' }, { value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'uk', label: 'Українська' }, { value: 'uk', label: 'Українська' },
{ value: 'zh', label: '简体中文' }, { value: 'zh', label: '简体中文' },
]; ];
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface PromptDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (value: string) => void;
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function PromptDialog({
isOpen,
onClose,
onSubmit,
title,
message,
placeholder,
defaultValue = "",
confirmText,
cancelText,
}: PromptDialogProps) {
const t = useTranslations("confirm_dialog");
const id = useId();
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setValue(defaultValue);
const t = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, defaultValue]);
useEffect(() => {
if (!isOpen) return;
const handleBackdropClick = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleBackdropClick);
return () => document.removeEventListener("mousedown", handleBackdropClick);
}, [isOpen, onClose, dialogRef]);
if (!isOpen) return null;
const resolvedConfirmText = confirmText || t("confirm");
const resolvedCancelText = cancelText || t("cancel");
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
try {
onSubmit(trimmed);
} finally {
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<form onSubmit={handleSubmit}>
<div className="p-6">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{message && (
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
)}
<Input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="mt-4"
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button type="button" variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{resolvedConfirmText}
</Button>
</div>
</form>
</div>
</div>
);
}
+13
View File
@@ -8,6 +8,14 @@ services:
- PORT=3000 - PORT=3000
env_file: env_file:
- .env.local - .env.local
volumes:
# Encrypted user settings (SETTINGS_DATA_DIR).
- bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
- bulwark-admin:/app/data/admin
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry
healthcheck: healthcheck:
test: test:
[ [
@@ -23,3 +31,8 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
restart: unless-stopped restart: unless-stopped
volumes:
bulwark-settings:
bulwark-admin:
bulwark-telemetry:
+3 -2
View File
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
import { useToastStore } from '@/stores/toast-store'; import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts'; import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
import { playNotificationSound } from '@/lib/notification-sound'; import { playNotificationSound } from '@/lib/notification-sound';
import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarEvent } from '@/lib/jmap/types';
const CHECK_INTERVAL_MS = 60 * 1000; const CHECK_INTERVAL_MS = 60 * 1000;
@@ -68,7 +69,7 @@ export function useCalendarAlerts() {
message, message,
duration: 15000, duration: 15000,
onClick: () => { onClick: () => {
window.location.href = `/${locale}/calendar`; window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
}, },
}); });
} }
@@ -97,7 +98,7 @@ export function useCalendarAlerts() {
message: taskMsg, message: taskMsg,
duration: 15000, duration: 15000,
onClick: () => { onClick: () => {
window.location.href = `/${locale}/calendar`; window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
}, },
}); });
} }
+7 -6
View File
@@ -22,6 +22,7 @@ interface UseContextMenuReturn<T> {
const MENU_WIDTH = 200; const MENU_WIDTH = 200;
const MENU_HEIGHT = 320; // Approximate max height const MENU_HEIGHT = 320; // Approximate max height
const VIEWPORT_MARGIN = 10;
export function useContextMenu<T>(): UseContextMenuReturn<T> { export function useContextMenu<T>(): UseContextMenuReturn<T> {
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({ const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
@@ -40,18 +41,18 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
let y = clientY; let y = clientY;
// Adjust for right edge // Adjust for right edge
if (x + MENU_WIDTH > viewportWidth - 10) { if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
x = viewportWidth - MENU_WIDTH - 10; x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN;
} }
// Adjust for bottom edge // Adjust for bottom edge
if (y + MENU_HEIGHT > viewportHeight - 10) { if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
y = viewportHeight - MENU_HEIGHT - 10; y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN;
} }
// Ensure minimum position // Ensure minimum position
x = Math.max(10, x); x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(10, y); y = Math.max(VIEWPORT_MARGIN, y);
return { x, y }; return { x, y };
}, []); }, []);
+27
View File
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { format } from "date-fns";
/**
* Returns a memoized function that formats a calendar event date
* using the current locale for day and month names.
*
* The string will be in the format: "EEE, MMM d, yyyy"
*
* For example: "Wed, Apr 29, 2026" (en)
* "Qua, Abr 29, 2026" (pt)
*/
export function useFormatEventDate(): (date: Date) => string {
const t = useTranslations("calendar");
return useCallback(
(date: Date): string => {
const dayOfWeek = format(date, "EEE").toLowerCase();
const month = format(date, "MMM").toLowerCase();
const day = format(date, "d");
const year = format(date, "yyyy");
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
},
[t]
);
}
+10 -6
View File
@@ -39,6 +39,7 @@ export interface UseKeyboardShortcutsOptions {
enabled?: boolean; enabled?: boolean;
emails: Email[]; emails: Email[];
selectedEmailId?: string; selectedEmailId?: string;
selectionCount?: number;
handlers: KeyboardShortcutHandlers; handlers: KeyboardShortcutHandlers;
} }
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts({
enabled = true, enabled = true,
emails, emails,
selectedEmailId, selectedEmailId,
selectionCount = 0,
handlers, handlers,
}: UseKeyboardShortcutsOptions) { }: UseKeyboardShortcutsOptions) {
const handlersRef = useRef(handlers); const handlersRef = useRef(handlers);
@@ -90,6 +92,8 @@ export function useKeyboardShortcuts({
// Shortcuts that should NOT work with modifiers // Shortcuts that should NOT work with modifiers
if (hasModifier) return; if (hasModifier) return;
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
switch (key) { switch (key) {
// Navigation // Navigation
case "j": case "j":
@@ -152,7 +156,7 @@ export function useKeyboardShortcuts({
break; break;
case "e": case "e":
if (selectedEmailId) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onArchive?.(); h.onArchive?.();
} }
@@ -161,28 +165,28 @@ export function useKeyboardShortcuts({
case "#": case "#":
case "delete": case "delete":
case "backspace": case "backspace":
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onDelete?.(); h.onDelete?.();
} }
break; break;
case "u": case "u":
if (selectedEmailId) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onMarkAsUnread?.(); h.onMarkAsUnread?.();
} }
break; break;
case "i": case "i":
if (selectedEmailId && event.shiftKey) { if (hasBatchTarget && event.shiftKey) {
event.preventDefault(); event.preventDefault();
h.onMarkAsRead?.(); h.onMarkAsRead?.();
} }
break; break;
case "!": case "!":
if (selectedEmailId) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onToggleSpam?.(); h.onToggleSpam?.();
} }
@@ -220,7 +224,7 @@ export function useKeyboardShortcuts({
break; break;
} }
}, },
[selectedEmailId] [selectedEmailId, selectionCount]
); );
useEffect(() => { useEffect(() => {
+4 -4
View File
@@ -31,7 +31,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore(); const { client } = useAuthStore();
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore(); const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target // Determine if this is a valid drop target
@@ -115,8 +115,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
clearSelection(); clearSelection();
} }
// Refresh the current mailbox view // Refresh the current mailbox view (honors active search/filters)
await fetchEmails(client, selectedMailbox); await refreshCurrentMailbox(client);
const mailboxPath = getMailboxPath(mailbox, mailboxes); const mailboxPath = getMailboxPath(mailbox, mailboxes);
@@ -144,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally { } finally {
endDrag(); endDrag();
} }
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]); }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget(); const valid = isValidTarget();
+87
View File
@@ -0,0 +1,87 @@
import { useState, useCallback, useRef, useEffect } from "react";
interface PromptDialogState {
isOpen: boolean;
title: string;
message?: string;
placeholder?: string;
defaultValue: string;
confirmText?: string;
cancelText?: string;
onSubmit: (value: string) => void;
}
const INITIAL_STATE: PromptDialogState = {
isOpen: false,
title: "",
defaultValue: "",
onSubmit: () => {},
};
interface PromptOptions {
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function usePromptDialog() {
const [state, setState] = useState<PromptDialogState>(INITIAL_STATE);
const resolveRef = useRef<((value: string | null) => void) | null>(null);
useEffect(() => {
return () => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
};
}, []);
const prompt = useCallback(
(options: PromptOptions): Promise<string | null> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({
isOpen: true,
title: options.title,
message: options.message,
placeholder: options.placeholder,
defaultValue: options.defaultValue ?? "",
confirmText: options.confirmText,
cancelText: options.cancelText,
onSubmit: (value) => {
resolveRef.current = null;
resolve(value);
},
});
});
},
[]
);
const close = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
setState(INITIAL_STATE);
}, []);
return {
dialogProps: {
isOpen: state.isOpen,
onClose: close,
onSubmit: state.onSubmit,
title: state.title,
message: state.message,
placeholder: state.placeholder,
defaultValue: state.defaultValue,
confirmText: state.confirmText,
cancelText: state.cancelText,
},
prompt,
};
}
+1
View File
@@ -80,6 +80,7 @@ export function useTimeGridInteractions({
dayDate: Date, dayDate: Date,
) => { ) => {
if (isMobile) return; if (isMobile) return;
if (e.pointerType === "touch") return;
if (e.button !== 0) return; if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return; if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
+6
View File
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
// Use static imports for better compatibility // Use static imports for better compatibility
let messages; let messages;
switch (locale) { switch (locale) {
case 'cs':
messages = (await import('../locales/cs/common.json')).default;
break;
case 'fr': case 'fr':
messages = (await import('../locales/fr/common.json')).default; messages = (await import('../locales/fr/common.json')).default;
break; break;
@@ -44,6 +47,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ru': case 'ru':
messages = (await import('../locales/ru/common.json')).default; messages = (await import('../locales/ru/common.json')).default;
break; break;
case 'tr':
messages = (await import('../locales/tr/common.json')).default;
break;
case 'uk': case 'uk':
messages = (await import('../locales/uk/common.json')).default; messages = (await import('../locales/uk/common.json')).default;
break; break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed'; | 'as-needed';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'], locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
defaultLocale: 'en', defaultLocale: 'en',
localePrefix localePrefix
}); });
+15 -37
View File
@@ -2,55 +2,33 @@ import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager"; import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password"; import { initAdminPassword } from "./lib/admin/password";
const VERSION_CHECK_URL =
"https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION";
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
function compareVersions(current: string, remote: string): number {
const a = current.split(".").map(Number);
const b = remote.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((b[i] ?? 0) > (a[i] ?? 0)) return 1;
if ((b[i] ?? 0) < (a[i] ?? 0)) return -1;
}
return 0;
}
const pkg = JSON.parse( const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8") readFileSync(`${process.cwd()}/package.json`, "utf-8")
); );
const current: string = pkg.version ?? "0.0.0"; const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`); console.info(`Bulwark Webmail v${current}`);
if (process.env.NODE_ENV === "production") {
fetch(VERSION_CHECK_URL, {
cache: "no-store",
signal: AbortSignal.timeout(5000),
})
.then((res) => {
if (!res.ok) return;
return res.text();
})
.then((text) => {
if (!text) return;
const remote = text.trim();
if (!SEMVER_RE.test(remote)) return;
if (compareVersions(current, remote) > 0) {
console.info(
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
);
}
})
.catch(() => {});
}
// Initialize admin config and password bootstrap // Initialize admin config and password bootstrap
configManager.load() configManager.load()
.then(() => initAdminPassword()) .then(() => initAdminPassword())
.then(() => { .then(() => {
console.info("Admin dashboard initialized"); console.info("Admin dashboard initialized");
}) })
.then(async () => {
// Anonymous telemetry - on by default. Admins can disable via the
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
const { startScheduler, markProcessStart } = await import("./lib/telemetry");
markProcessStart();
await startScheduler();
})
.then(async () => {
// Hourly check against version.telemetry.bulwarkmail.org. Disable with
// BULWARK_UPDATE_CHECK=off or override the endpoint with
// BULWARK_UPDATE_CHECK_URL.
const { startScheduler } = await import("./lib/version-check");
await startScheduler();
})
.catch((err) => { .catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err); console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
}); });
+1 -1
View File
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
mayWriteOwn: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayUpdatePrivate: true,
mayRSVP: true, mayRSVP: true,
mayAdmin: false, mayShare: false,
mayDelete: false, mayDelete: false,
}, },
...overrides, ...overrides,
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import {
isValidFrameOrigin,
sanitizeFrameOrigins,
} from '@/lib/admin/csp-frame-origins';
describe('isValidFrameOrigin', () => {
it('accepts plain https origins', () => {
expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
});
it('accepts a wildcard subdomain', () => {
expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
});
it('accepts an explicit port', () => {
expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
});
it('rejects non-https schemes', () => {
expect(isValidFrameOrigin('http://example.com')).toBe(false);
expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
});
it('rejects bare schemes and wildcard hosts', () => {
expect(isValidFrameOrigin('https://')).toBe(false);
expect(isValidFrameOrigin('https://*')).toBe(false);
expect(isValidFrameOrigin('https://*.com')).toBe(false);
expect(isValidFrameOrigin('https://localhost')).toBe(false);
});
it('rejects paths, queries, and fragments', () => {
expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
expect(isValidFrameOrigin('https://example.com/')).toBe(false);
expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
});
it('rejects userinfo, IPs, and IPv6', () => {
expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
expect(isValidFrameOrigin('https://[::1]')).toBe(false);
});
it('rejects values that try to break out of the directive', () => {
expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
});
it('rejects non-strings and obvious garbage', () => {
expect(isValidFrameOrigin(undefined)).toBe(false);
expect(isValidFrameOrigin(null)).toBe(false);
expect(isValidFrameOrigin(42)).toBe(false);
expect(isValidFrameOrigin('')).toBe(false);
expect(isValidFrameOrigin('not-a-url')).toBe(false);
expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
});
});
describe('sanitizeFrameOrigins', () => {
it('returns empty for non-array input', () => {
expect(sanitizeFrameOrigins(undefined)).toEqual([]);
expect(sanitizeFrameOrigins(null)).toEqual([]);
expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
expect(sanitizeFrameOrigins({})).toEqual([]);
});
it('keeps valid entries and drops invalid ones silently', () => {
expect(
sanitizeFrameOrigins([
'https://www.youtube-nocookie.com',
'http://insecure.com',
'https://meet.example.com:8443',
'https://example.com/path',
42,
'https://*.vimeo.com',
]),
).toEqual([
'https://www.youtube-nocookie.com',
'https://meet.example.com:8443',
'https://*.vimeo.com',
]);
});
it('dedupes case-insensitively', () => {
expect(
sanitizeFrameOrigins([
'https://Example.com',
'https://example.com',
'https://EXAMPLE.com',
]),
).toEqual(['https://Example.com']);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
computeReplyThreadingHeaders,
stripMessageIdBrackets,
} from '../email-threading';
describe('stripMessageIdBrackets', () => {
it('strips surrounding angle brackets', () => {
expect(stripMessageIdBrackets('<abc@example.com>')).toBe('abc@example.com');
});
it('handles whitespace and missing brackets', () => {
expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com');
expect(stripMessageIdBrackets('<abc@example.com')).toBe('abc@example.com');
expect(stripMessageIdBrackets('abc@example.com>')).toBe('abc@example.com');
});
});
describe('computeReplyThreadingHeaders', () => {
it('returns null when the parent has no Message-ID', () => {
expect(computeReplyThreadingHeaders(undefined)).toBeNull();
expect(computeReplyThreadingHeaders({})).toBeNull();
expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull();
expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull();
});
it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => {
const result = computeReplyThreadingHeaders({
messageId: '<root@example.com>',
});
expect(result).toEqual({
inReplyTo: ['root@example.com'],
references: ['root@example.com'],
});
});
it('appends the parent to existing References per RFC 5322', () => {
const result = computeReplyThreadingHeaders({
messageId: '<msg-2@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'],
});
});
it('de-duplicates if the parent already appears in References', () => {
const result = computeReplyThreadingHeaders({
messageId: '<msg-1@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
expect(result?.references).toEqual([
'msg-0@example.com',
'msg-1@example.com',
]);
});
it('accepts bare Message-IDs without angle brackets', () => {
const result = computeReplyThreadingHeaders({
messageId: 'msg-2@example.com',
references: ['msg-1@example.com'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-1@example.com', 'msg-2@example.com'],
});
});
// JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we
// don't crash on that shape even though most call sites pass a string.
it('accepts an array-shaped messageId per JMAP spec', () => {
const result = computeReplyThreadingHeaders({
messageId: ['<msg-2@example.com>'],
references: ['<msg-1@example.com>'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-1@example.com', 'msg-2@example.com'],
});
});
it('returns null for an empty messageId array', () => {
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull();
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
username: 'user@example.com',
});
return client;
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
2: string;
}
interface CapturedRequest {
using?: string[];
methodCalls: JMAPMethodCall[];
}
/**
* Mock fetch to script three sequential JMAP requests sendEmail makes:
* Mailbox/get Identity/get Email/set + EmailSubmission/set.
* Returns the captured request bodies for assertions.
*/
function mockSendEmailFlow() {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
captured.push(body);
const callIdx = captured.length - 1;
let payload: unknown;
if (callIdx === 0) {
payload = {
methodResponses: [[
'Mailbox/get',
{
list: [
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
],
},
'0',
]],
};
} else if (callIdx === 1) {
payload = {
methodResponses: [[
'Identity/get',
{ list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] },
'0',
]],
};
} else {
payload = {
methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
],
};
}
return {
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(payload)),
json: () => Promise.resolve(payload),
} as Response;
});
return captured;
}
describe('JMAPClient.sendEmail threading headers', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('writes inReplyTo and references on the Email/set create when supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'reply body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<parent@example.com>'],
['<root@example.com>', '<parent@example.com>'],
);
// Third request is the Email/set + EmailSubmission/set batch.
const setCall = captured[2].methodCalls[0];
expect(setCall[0]).toBe('Email/set');
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
// Bare msg-ids per RFC 8621 — angle brackets stripped.
expect(draft.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
});
it('omits threading fields when no parent ids are supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Fresh thread',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toBeUndefined();
expect(draft.references).toBeUndefined();
});
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<>', ' ', '<real@example.com>'],
[],
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More