Compare commits

..
146 Commits
Author SHA1 Message Date
Linus Rath 1fc6185002 chore: update version to 1.7.1 2026-05-22 12:22:03 +02:00
Linus Rath 4269d0589c feat: collapse empty viewer pane and hide placeholder in Pro mode 2026-05-22 12:19:34 +02:00
Linus Rath 2b1b06abd6 fix: collapse empty viewer pane so mail list fills the space 2026-05-22 12:16:49 +02:00
Linus Rath ac4a89120d fix: preserve inline images when replying #163 2026-05-22 12:06:55 +02:00
Linus Rath 704a259432 feat: hide empty-state placeholder in email viewer pane 2026-05-22 12:04:40 +02:00
Linus Rath 1c02970ae1 fix: use canonical INBOX in Sieve filter paths #313 2026-05-22 11:51:25 +02:00
Linus Rath 66b2036e37 feat: expose PWA branding fields in admin Branding tab 2026-05-22 11:22:07 +02:00
Linus Rath bd2ffab3bc fix: resolve destination account id to local namespace in mailbox drop 2026-05-22 11:20:50 +02:00
Linus Rath 7142627cec chore: update version to 1.7.0 2026-05-22 00:51:38 +02:00
Linus Rath 1e7d2d880c i18n: add missing translation keys across 16 locales 2026-05-22 00:44:00 +02:00
Linus Rath 63efd724d2 fix: trust directory version on marketplace install/update 2026-05-22 00:20:00 +02:00
Linus Rath ba4781910d feat: marketplace update flow for installed plugins/themes 2026-05-22 00:11:10 +02:00
Linus Rath 08c85a42e1 chore: update version to 1.7.0 2026-05-21 23:47:31 +02:00
Linus Rath fc5f6f43d6 feat: expose PWA, app identity, and extension directory keys in JSON config #312 2026-05-21 23:35:58 +02:00
Linus Rath 9b22ef810e fix: allow adding contacts from mail recipient popover on mobile #306 2026-05-21 23:23:45 +02:00
Linus Rath dbc0eea148 feat: group composer From dropdown by account in Pro shell 2026-05-21 23:07:02 +02:00
Linus Rath e80412b6fd feat: show contacts from all logged-in accounts in Pro shell 2026-05-21 22:32:17 +02:00
Linus Rath 2c825af689 fix: show avatars in calendar/address book sharing menu 2026-05-21 18:58:46 +02:00
Linus Rath 4cfee4f672 feat: split owned vs shared calendars per account in sidebar in Pro shell 2026-05-21 18:54:09 +02:00
Linus Rath 7076f1ded8 feat: show calendars from all logged-in accounts in Pro shell 2026-05-21 18:48:55 +02:00
Linus Rath 9fbcdf7a5f fix: hide mail sidebar header in Pro shell 2026-05-21 18:19:30 +02:00
Linus Rath 7f35d792b2 fix: load globals.css and Geist font in plugin sandbox iframe 2026-05-21 18:15:00 +02:00
Linus Rath c9cda3e203 fix: parent navigation detaching account in Pro shell file browser 2026-05-21 18:06:59 +02:00
Linus Rath 3540bf42d9 fix: use Avatar component in Pro shell file account picker 2026-05-21 18:05:03 +02:00
Linus Rath e6782e61a8 fix: sync plugin slot iframe height with reported content height 2026-05-21 17:59:24 +02:00
Linus Rath 33e655bcce feat: narrow-pane sidebars and cross-account file picker in Pro shell 2026-05-21 17:54:03 +02:00
Linus Rath 15a67a14b3 fix: parent dir navigation jumping to root in file browser 2026-05-21 17:27:49 +02:00
Linus Rath b48eef2189 feat: hide files back button in Pro shell 2026-05-21 17:25:01 +02:00
Linus Rath 280f5bc675 feat: support cross-account email moves in Pro shell 2026-05-21 17:22:31 +02:00
Linus Rath 1756f5ac1c feat: enable search in unified mailbox in pro mode 2026-05-21 17:12:06 +02:00
Linus Rath c9435f7580 feat: always show unified mailbox in Pro shell 2026-05-21 17:01:34 +02:00
Linus Rath 426d344aa8 feat: multi-account mail sidebar and client routing for Pro shell 2026-05-21 16:52:03 +02:00
Linus Rath ed90e096b5 feat: add per-account mailbox cache for Pro shell data layer 2026-05-21 16:11:26 +02:00
Linus Rath eb6e5f589e fix: hide redundant account switcher in mail sidebar inside Pro shell 2026-05-21 15:54:33 +02:00
Linus Rath eca837962c fix: hide "Back to Mail" in settings when Pro mode is on 2026-05-21 15:49:09 +02:00
Linus Rath b75bbaa517 feat: auto-redirect to Pro shell when proInterface is on 2026-05-21 15:47:05 +02:00
Linus Rath 9763ffa2a3 fix: keep proInterface per-device instead of syncing it 2026-05-21 15:39:02 +02:00
Linus Rath d854b903e0 fix: anchor unmatched URLs into main so 404 renders 2026-05-20 23:59:10 +02:00
Linus Rath 5008857880 fix: respect server-resolved locale on first visit #309 2026-05-20 23:53:44 +02:00
Linus Rath 628966d3b5 fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly 2026-05-20 23:41:49 +02:00
Linus Rath d45c8ef511 feat: list and reorder logged-in accounts in settings #282 2026-05-20 19:22:44 +02:00
Linus Rath ba90ec1f7a feat: warn when setup JMAP URL points at a local-only host 2026-05-20 19:11:46 +02:00
Linus Rath 5023d31202 fix: defer setup wizard HTTP detection to avoid hydration mismatch 2026-05-20 19:05:11 +02:00
Linus Rath 1c44f59ba1 feat: allow setup wizard over plain HTTP with dismissable warning gate 2026-05-20 19:01:46 +02:00
Linus Rath 433a63bf1a fix: normalize malformed contact photo data URIs #307 2026-05-20 18:42:12 +02:00
Linus Rath de847b9e9f i18n: add missing translation keys across 16 locales 2026-05-20 18:30:50 +02:00
Linus Rath d530d9614b fix: serialize draft autosave with send to stop replies stalling in Drafts #303 2026-05-19 23:33:11 +02:00
Linus Rath 9a92271f6f fix: prevent mobile dual-scroll and use full width for mail content 2026-05-19 14:38:28 +02:00
Linus Rath 97ddf935a8 fix: mobile handoff flow for OAuth authentication 2026-05-19 00:45:35 +02:00
Linus Rath 973ce1e5bd feat: add mobile handoff page and JMAP authentication verification 2026-05-19 00:05:40 +02:00
Linus Rath 7003020855 fix: prevent duplication of Bulwark rules with literal braces in values 2026-05-18 23:58:21 +02:00
Linus Rath 5cdc5997af feat: pro: pane-aware responsiveness, scoped sidebar overlay, stable pane keys 2026-05-18 20:42:21 +02:00
Linus Rath c3b4707f85 feat: pro: drop top/bottom split, keep side-by-side only 2026-05-18 19:51:43 +02:00
Linus Rath 2b3094a4ef feat: pro: unify split panes under a single tab bar 2026-05-18 19:39:57 +02:00
Linus Rath ecd0467ffa fix: stop pulling node:dns into client bundle via OAuth discovery 2026-05-18 19:31:38 +02:00
Linus Rath 43ac0725ce feat: pro: drag tabs to reorder, drag to edge to split 2026-05-18 19:30:54 +02:00
Linus Rath 98879802ae feat: add Pro interface 2026-05-18 19:17:39 +02:00
Linus Rath cf9292262d feat: pluggable reply/forward quote header #295 2026-05-18 17:53:23 +02:00
Linus Rath 3d2ed71f3a feat: support multiple flexible event reminders #170 2026-05-18 17:31:44 +02:00
Linus Rath dcd2f4b079 fix: wire orphaned admin policy gates and surface OAuth scope settings 2026-05-18 16:47:20 +02:00
Linus Rath b2d24670ff fix: scope iCal subscriptions per JMAP account and fix refresh/clear 2026-05-18 16:39:02 +02:00
Linus Rath fa261fecfd fix: omit empty cc/bcc from Email/set so server does not emit bare Cc: header #301 2026-05-18 16:36:45 +02:00
Linus Rath 40f36baf94 fix: iCal subscription refresh, rollback, and URL normalization 2026-05-18 16:31:16 +02:00
Linus Rath 400703154e fix: ignore plugin-supplied target in ui.openExternalUrl to block host-frame hijack 2026-05-18 16:18:00 +02:00
Linus Rath 3ceada7b8a fix: tighten HTML sanitization at plain-text email + signature + i18n render sites 2026-05-18 16:07:34 +02:00
Linus Rath eb0643d887 fix: pin parent origin in iframe-bridge to block cross-frame postMessage 2026-05-18 16:01:29 +02:00
Linus Rath 1fc670138b fix: update bundleHash to full SHA-256 for integrity verification and migrate legacy hashes 2026-05-18 15:57:52 +02:00
Linus Rath 313a1fcce9 fix: stop persisting S/MIME passphrases in sessionStorage 2026-05-18 15:32:04 +02:00
Linus Rath 7efd8d59bf fix: escape print-window fields and re-sanitize body to block XSS 2026-05-18 13:24:52 +02:00
Linus Rath c2eb2c081b fix: gate admin routes against cross-origin CSRF 2026-05-18 13:21:01 +02:00
Linus Rath b299a0b602 fix: validate plugin/theme id in marketplace install to block path traversal 2026-05-18 13:03:49 +02:00
Linus Rath f275fbe2e4 fix: bind stalwart auth context to credential, not cookie-claimed username 2026-05-18 13:00:40 +02:00
Linus Rath f134766fd1 fix: validate OAuth discovery endpoints against SSRF 2026-05-18 12:53:43 +02:00
Linus Rath 6ebf720688 fix: block script-bearing MIME types from inline attachment preview 2026-05-18 12:47:44 +02:00
Linus Rath b1eb2b3c9b fix: correct regex for valid API post path validation 2026-05-18 12:44:54 +02:00
Linus Rath 48aa607b56 feat: lock down plugin runtime in sandbox + signing + approval 2026-05-18 12:44:23 +02:00
Linus Rath 088810bd20 feat: harden plugin sandbox and migrate in-tree plugins 2026-05-18 12:17:54 +02:00
Linus Rath e16f572252 fix: use plugin slot offer snapshots for useSyncExternalStore 2026-05-18 11:00:05 +02:00
Linus Rath 9f312aa556 feat: sandbox plugins in null-origin iframes with postMessage RPC 2026-05-18 10:50:49 +02:00
Linus Rath c5ac68e137 fix: prevent plugin config leak to non-admin users 2026-05-18 10:24:29 +02:00
Linus Rath ed6b5d5f33 fix: clear identity signature fields when emptied 2026-05-18 00:53:54 +02:00
Linus Rath 7a72903632 feat: show size cap on identity signature fields 2026-05-18 00:34:01 +02:00
Linus Rath 92127e2f00 fix: allow table-based layouts in HTML signature sanitizer 2026-05-18 00:24:08 +02:00
Linus Rath be5ff96e4d fix: toggle recipient popover when clicking name again 2026-05-17 23:45:57 +02:00
Linus Rath 1f47b7a6a9 fix: remove white halo around photo avatars 2026-05-17 23:44:13 +02:00
Linus RathandGitHub 551984ac44 Bump version from 1.6.6 to 1.6.7 2026-05-17 19:22:07 +02:00
Linus Rath 8c5aec9ca4 chore: update version to 1.6.7 2026-05-17 18:17:13 +02:00
Linus Rath 375220298d i18n: add missing translation keys across 16 locales 2026-05-17 18:12:17 +02:00
Linus Rath 452976ed95 fix: apply dark background to email content wrapper in dark mode 2026-05-17 17:40:35 +02:00
Linus Rath 243a2adfbf fix: improve dark mode background colors in email viewer 2026-05-17 17:39:49 +02:00
Linus Rath 1ba4a13353 fix: show "no body content" instead of infinite skeleton for bodyless emails 2026-05-17 17:33:06 +02:00
Linus Rath 5de12dfb79 perf: speed up calendar invitation banner load
Parallelize ICS parse with raw blob fetch, render the banner as soon
as parsing returns instead of awaiting the existing-event lookup, and
filter that lookup by UID server-side instead of fetching every event
on the calendar.
2026-05-17 17:28:10 +02:00
Linus Rath 689d646c57 fix: show contact popup when clicking sender name in email header 2026-05-17 17:19:43 +02:00
Linus Rath 49cd7f8130 feat: show details toggle and panel on mobile sender info 2026-05-17 17:15:19 +02:00
Linus Rath 4545e212f4 fix: align quick reply with mobile bottom toolbar 2026-05-17 17:06:30 +02:00
Linus Rath b1f4f6eae0 fix: pin quick reply to bottom for short emails 2026-05-17 16:56:54 +02:00
Linus Rath 9a431a873b fix: close attachment preview when clicking outside content 2026-05-17 16:49:13 +02:00
Linus Rath bb7e1c4538 fix: per-account push subscriptions so multi-account notifications work #298 2026-05-16 22:50:01 +02:00
Linus Rath 356abcfc2d fix: redact sensitive config secrets from admin API response 2026-05-16 22:48:06 +02:00
Linus Rath 3099b4801e fix: sandbox thread email HTML in srcDoc iframe with CSP meta 2026-05-16 20:49:59 +02:00
Linus Rath fc641e94ac fix: carry configSchema + settingsSchema through marketplace install 2026-05-16 19:51:59 +02:00
Linus Rath 0e758409ee fix: prevent long addresses from overflowing email details columns #297 2026-05-16 19:47:03 +02:00
Linus Rath 8c93941d8d feat: render app-top-banner slot on every authenticated page 2026-05-16 19:39:45 +02:00
Linus Rath 4221c9a50f fix: strip Stalwart master-user '%' suffix from displayed account 2026-05-16 19:07:10 +02:00
Linus Rath 3a559479bd fix: make impersonation cookies session-only 2026-05-16 18:59:46 +02:00
Linus Rath 482493a10d fix: register app-top-banner in plugin-store SLOT_NAMES 2026-05-16 18:53:06 +02:00
Linus Rath 0e1036eb49 fix: adopt orphan session cookie on first SPA load 2026-05-16 18:45:30 +02:00
Linus Rath 349406723c fix: use relative Location header in redirect 2026-05-16 18:33:44 +02:00
Linus Rath 997bedc91b feat: allow admin password overwrite during setup recovery 2026-05-16 18:21:48 +02:00
Linus Rath 307e6d5d34 fix: warn + block install when app version is below plugin's minAppVersion 2026-05-16 18:11:52 +02:00
Linus Rath ca1108f455 feat: master-user impersonation route + app-top-banner plugin slot 2026-05-16 17:59:07 +02:00
Linus Rath 0ff88f36ed Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-16 16:35:43 +02:00
Linus Rath 285b4e349c fix: add outputFileTracingExcludes to optimize Turbopack memory tracing 2026-05-16 16:35:06 +02:00
Linus Rath 2b4ebb1fbb feat: add HTTPS requirement warning in setup wizard 2026-05-16 16:31:12 +02:00
Timo StreuleandLinus Rath a829c2818f fix: pad safe-area-inset-top 2026-05-16 00:55:24 +02:00
Timo StreuleandLinus Rath c54cf73c3a fix: respect safe-area insets on mobile bottom bars 2026-05-15 23:50:29 +02:00
Timo StreuleandLinus Rath c45ef86924 fix: add viewport export with 'initialScale: 1' 2026-05-15 23:01:39 +02:00
Linus Rath f39366b470 fix: read OAUTH_SCOPES at runtime instead of build time 2026-05-15 20:37:00 +02:00
Linus Rath b725000f4d feat: implement vCard 4.0 parsing and generation support 2026-05-15 20:10:18 +02:00
Linus Rath 105194a8b9 chore: update version to 1.6.6 2026-05-15 15:20:07 +02:00
Linus Rath 8dbb538c98 feat: sync onboarding status across devices #285 2026-05-15 15:09:42 +02:00
Linus Rath e435356c53 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-15 14:49:58 +02:00
Linus Rath 6f9982540c feat: add icons for shared, important, memos, scheduled, snoozed folders #288 2026-05-15 14:48:39 +02:00
Timo StreuleandLinus Rath d0d6632b24 chore: drop redundant '-- ' prefix from dev identity signatures
The signature separator is already controlled by the
signatureSeparatorEnabled setting (lib/email-composer), which prepends
'-- ' at compose time when enabled. Baking it into the fixture
double-prefixed it.
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 4b7009dfc2 feat: raise HTML signature length cap to 50000 chars
5000 chars is too tight for signatures containing base64-embedded images (even a small PNG can run a few thousand chars).
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 55a408e810 feat: allow img in HTML identity signatures
- Restricts src to https: URLs or base64-embedded raster data: URIs (png/jpeg/gif/webp).
- SVG is excluded for safety reasons.
- Images with a disallowed src are removed entirely so they don't render as broken-image icons.
2026-05-15 14:46:43 +02:00
Linus Rath d5dddba6df fix: hide Files settings/nav when filesEnabled policy is off #291 2026-05-15 14:41:57 +02:00
Linus Rath d1a0667c79 i18n: clean up Danish locale wiring and sort language lists 286 2026-05-15 14:31:24 +02:00
Jesper OrdrupandLinus Rath e700e4fd04 match any translation 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath cf993c1036 adjust flag 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath 5fdf226ebe feat(i18n): add danish localization 2026-05-15 14:26:33 +02:00
Linus Rath fae15f073e fix: honor cookieSameSite admin config override #284 2026-05-14 21:49:37 +02:00
Linus Rath c646c87030 fix: standardize punctuation in tooltips and comments across multiple locales and code files 2026-05-14 21:44:24 +02:00
Linus Rath b4a76bc4d1 chore: expand demo fixtures with more emails, contacts, and portrait photos 2026-05-14 15:19:24 +02:00
Linus Rath dfe886636b fix: broaden body font for non Latin script rendering #265 2026-05-13 14:43:07 +02:00
Linus Rath f499e87d2a chore: update version to 1.6.5 2026-05-13 14:38:00 +02:00
Linus Rath 32fe871b70 fix: support HTTP basic auth in iCal subscription URLs #275 2026-05-13 14:27:54 +02:00
Linus Rath aab19379e2 feat: route account avatars through shared Avatar component #278 2026-05-13 00:50:46 +02:00
Linus Rath b46a1a69e8 chore: unblock pre-commit lint hook 2026-05-13 00:34:35 +02:00
Linus Rath ea424cad7e fix: honor admin-uploaded favicon in root metadata #274 2026-05-13 00:33:23 +02:00
Lucas GaitzschandLinus Rath 3f444a8912 Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links
* Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab
* Added webcal: protocol handling for calendar links
* Added account selection for webcal: links when multiple calendar-capable accounts are connected
* Added an import-or-subscribe choice for detected webcal calendars
* Added protocol handler settings for registering mail and calendar handlers and choosing the open mode
* Added service worker/session coordination for passing protocol requests between browser/PWA contexts
* Added tests and translations for the new protocol handler flows
2026-05-12 20:49:05 +02:00
Linus Rath 8b0e2052cf fix: honor NEXT_PUBLIC_BASE_PATH in admin sidebar nav links #271 2026-05-12 16:10:29 +02:00
Linus Rath c99934a92c fix: update version to 1.6.4 2026-05-12 16:06:14 +02:00
Linus Rath ce2731cd9d fix: update types for cursor and toRemove 2026-05-12 16:04:46 +02:00
Linus Rath f9f8af2f11 fix: preserve signature styling and reactivity in above-quote mode #272 2026-05-12 16:03:10 +02:00
Linus Rath d8e2a10806 docs: update CONTRIBUTING.md 2026-05-11 20:41:04 +02:00
269 changed files with 20732 additions and 3639 deletions
+175
View File
@@ -1,5 +1,180 @@
# Changelog # Changelog
## 1.7.1 (2026-05-22)
### Features
- **Admin**: Expose PWA branding fields in the admin Branding tab
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
### Fixes
- **Mail**: Preserve inline images when replying (#163)
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
## 1.7.0 (2026-05-21)
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
### Breaking Changes
- **Plugins**: Plugins now run inside a null-origin iframe sandbox and talk to the host over a postMessage RPC bridge. The in-process plugin runtime is gone; the bundled in-tree plugins have been migrated. Third-party plugins built against the old in-process API need to be ported to the sandboxed runtime.
- **Plugins**: Server-managed bundles must be Ed25519-signed by the host and approved by an admin before they load. The host public key is served from `/api/plugin-signing-pubkey` and each bundle response carries the signature in the `X-Bundle-Signature` header. User-uploaded bundles still load unsigned, but managed marketplace and dev-folder bundles do not.
- **Plugins**: `bundleHash` is now a full SHA-256 over the bundle. Legacy short hashes are migrated on first load; any out-of-band tooling that pinned the old hash format needs to be updated.
### Features
- **Pro**: Tabbed shell with drag-to-reorder, drag-to-edge to split, side-by-side panes, and pane-aware responsive layout with a scoped sidebar overlay
- **Pro**: Auto-redirect to the Pro shell when Pro mode is on; `proInterface` is kept per-device instead of syncing
- **Pro**: Multi-account mail sidebar with client routing and a per-account mailbox cache
- **Pro**: Unified mailbox always visible, with full-text search
- **Pro**: Cross-account email moves
- **Pro**: Multi-account calendar sidebar split into owned vs shared per account
- **Pro**: Multi-account contacts and a cross-account file picker
- **Pro**: Composer From dropdown grouped by account
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
- **Plugins**: Marketplace update flow for installed plugins and themes
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
- **Setup**: Warn when the JMAP URL points at a local-only host
- **Account**: List and reorder logged-in accounts from settings (#282)
- **Mail**: Mobile handoff page with JMAP authentication verification for cross-device OAuth
- **Mail**: Pluggable reply/forward quote header (#295)
- **Calendar**: Support multiple flexible event reminders (#170)
- **Admin**: Expose PWA, app identity, and extension directory keys in the JSON config (#312)
- **Admin**: Surface OAuth scope settings and wire up orphaned admin policy gates
### Security
- **Plugins**: Pin parent origin in the iframe bridge to block cross-frame postMessage
- **Plugins**: Ignore plugin-supplied `target` in `ui.openExternalUrl` to block host-frame hijack
- **Plugins**: Validate plugin/theme id in marketplace install to block path traversal
- **Plugins**: Prevent plugin config from leaking to non-admin users
- **Admin**: Gate admin routes against cross-origin CSRF
- **Auth**: Bind Stalwart auth context to the credential, not the cookie-claimed username
- **Auth**: Validate OAuth discovery endpoints against SSRF
- **Mail**: Tighten HTML sanitization at plain-text email, signature, and i18n render sites
- **Mail**: Block script-bearing MIME types from inline attachment preview
- **Mail**: Escape print-window fields and re-sanitize body to block XSS
- **S/MIME**: Stop persisting passphrases in `sessionStorage`
- **API**: Correct regex for valid API POST path validation
### Fixes
- **Mail**: Serialize draft autosave with send to stop replies stalling in Drafts (#303)
- **Mail**: Omit empty cc/bcc from `Email/set` so the server does not emit a bare `Cc:` header (#301)
- **Mobile**: Allow adding contacts from the mail recipient popover (#306)
- **Mobile**: Prevent dual-scroll and use full width for mail content
- **Mobile**: OAuth handoff flow
- **Calendar**: Scope iCal subscriptions per JMAP account; fix refresh and clear
- **Calendar**: iCal subscription refresh, rollback, and URL normalization
- **Calendar**: Show avatars in the calendar/address book sharing menu
- **Contacts**: Normalize malformed contact photo data URIs (#307)
- **Identity**: Clear identity signature fields when emptied
- **Identity**: Show size cap on identity signature fields
- **Identity**: Allow table-based layouts in the HTML signature sanitizer
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
- **Plugins**: Sync plugin slot iframe height with reported content height
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
- **Plugins**: Trust the directory version on marketplace install and update
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
- **Routing**: Respect server-resolved locale on first visit (#309)
- **Routing**: Split app into `(main)`/`(sandbox)` route groups so the plugin iframe hydrates properly
- **Files**: Stop parent directory navigation from jumping to root
- **Build**: Stop pulling `node:dns` into the client bundle via OAuth discovery
- **UI**: Toggle recipient popover when clicking the name again
- **UI**: Remove white halo around photo avatars
### i18n
- Add missing translation keys across 16 locales
## 1.6.7 (2026-05-17)
### Features
- **Contacts**: vCard 4.0 parsing and generation support
- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page
- **Admin**: Allow admin password overwrite during setup recovery
- **Setup**: HTTPS requirement warning in the setup wizard
- **Mobile**: Show details toggle and expandable panel for sender info
### Performance
- **Calendar**: Speed up calendar invitation banner load
### Security
- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `<meta>` tag
- **Admin**: Redact sensitive config secrets from the admin API response
- **Admin**: Make impersonation cookies session-only
### Fixes
- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time
- **Auth**: Use a relative `Location` header in redirects
- **Auth**: Adopt orphan session cookie on first SPA load
- **Mail**: Per-account push subscriptions so multi-account notifications work (#298)
- **Mail**: Close attachment preview when clicking outside the content area
- **Mail**: Pin quick reply to the bottom for short emails
- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails
- **Mail**: Show contact popup when clicking the sender name in the email header
- **Mail**: Prevent long addresses from overflowing email details columns (#297)
- **Mobile**: Align quick reply with the mobile bottom toolbar
- **Mobile**: Respect safe-area insets on mobile bottom bars
- **Mobile**: Pad `safe-area-inset-top`
- **UI**: Apply dark background to the email content wrapper in dark mode
- **UI**: Improve dark mode background colors in the email viewer
- **UI**: Add viewport export with `initialScale: 1`
- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account
- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion`
- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES`
- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install
- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing
### i18n
- Add missing translation keys across 16 locales
## 1.6.6 (2026-05-15)
### Features
- **Mail**: Sync onboarding completion state across devices so the welcome flow only runs once per account (#285)
- **Mail**: Distinct icons for Shared, Important, Memos, Scheduled, and Snoozed folders (#288)
- **Compose**: Raise HTML identity signature length cap to 50,000 characters
- **Compose**: Allow `<img>` tags in HTML identity signatures for inline logos and banners
### Fixes
- **Files**: Hide Files settings entry and sidebar nav when the `filesEnabled` policy is off (#291)
- **Admin**: Honor the `cookieSameSite` admin config override instead of always defaulting (#284)
- **UI**: Standardize punctuation in tooltips and inline comments across locales
### i18n
- Add Danish localization
- Clean up Danish locale wiring and sort the language picker alphabetically (#286)
## 1.6.5 (2026-05-13)
### Features
- **Protocol**: Register as the system handler for `mailto:` and `webcal:` links from a new protocol handler settings page
- **Protocol**: Account picker for protocol links when multiple accounts are connected
- **Protocol**: Import-or-subscribe choice for detected webcal calendars
- **Protocol**: Reuse the open PWA/session for `mailto:` links instead of always opening a new tab
- **UI**: Route account avatars through the shared `Avatar` component for consistent fallbacks (#278)
### Fixes
- **Calendar**: Support HTTP basic auth in iCal subscription URLs (#275)
- **Admin**: Honor admin-uploaded favicon in root metadata (#274)
- **Admin**: Honor `NEXT_PUBLIC_BASE_PATH` in admin sidebar nav links (#271)
- **UI**: Broaden body font stack so Thai (and other non-Latin scripts) render correctly in subjects, sender names, and other chrome (#265)
## 1.6.4 (2026-05-11) ## 1.6.4 (2026-05-11)
### Web Setup Wizard ### Web Setup Wizard
+26 -34
View File
@@ -10,14 +10,17 @@
# Contributing to Bulwark Webmail # Contributing to Bulwark Webmail
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
## Join our Community If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
* **Get Support:** Get real-time help with development hurdles. ## Join the Community
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better. You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
- **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements
- **Collaborate** - meet the team and other contributors
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT) [**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
## Internationalization (i18n) ## Internationalization (i18n)
This project uses **next-intl** for internationalization. Please follow these guidelines: This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
### Key Rules ### Rules
1. **Never hardcode user-facing text** - Always use translations: 1. **Never hardcode user-facing text** - always use translations:
```tsx ```tsx
const t = useTranslations("namespace"); const t = useTranslations("namespace");
return <div>{t("key")}</div>; return <div>{t("key")}</div>;
``` ```
2. **Translation file locations**: 2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
- English: `/locales/en/common.json`
- French: `/locales/fr/common.json`
3. **Namespace organization**: 3. **Namespace organization**:
- `login.*` - Login page strings - `login.*` - login page
- `sidebar.*` - Sidebar navigation - `sidebar.*` - sidebar navigation
- `email_list.*` - Email list component - `email_list.*` - email list
- `email_viewer.*` - Email viewer component - `email_viewer.*` - email viewer
- `email_composer.*` - Email composer - `email_composer.*` - composer
- `common.*` - Shared strings - `settings.*` - settings page
- `notifications.*` - Toast/alert messages - `notifications.*` - toasts and alerts
- `settings.*` - Settings page - `common.*` - shared strings
4. **Adding new strings**: 4. **Locale-aware navigation**:
- Add to **both** English and French translation files
- Use descriptive, hierarchical keys
- Keep translations consistent in tone
5. **Locale-aware navigation**:
```tsx ```tsx
router.push(`/${params.locale}/settings`); router.push(`/${params.locale}/settings`);
``` ```
@@ -203,16 +200,11 @@ webmail/
## Security ## Security
- **Never commit sensitive data** (API keys, passwords, etc.) - **Never commit secrets** - API keys, passwords, tokens, `.env*` files
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default for privacy - **Block external content** by default - privacy is the point
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems) - **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
## Questions? ## Questions?
If you have questions about contributing, feel free to: Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
- Open an issue for discussion
- Check existing issues and pull requests
Thank you for helping improve Bulwark Webmail!
+1 -1
View File
@@ -98,7 +98,7 @@
## Internationalization ## Internationalization
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська 17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
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`.
+1 -1
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.6.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.7.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)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
+1 -1
View File
@@ -1 +1 @@
1.6.3 1.7.1
+10
View File
@@ -0,0 +1,10 @@
import { notFound } from 'next/navigation';
// Catch-all that anchors unmatched URLs into the (main) route group so
// Next renders app/(main)/not-found.tsx (wrapped by (main)/layout.tsx)
// instead of the built-in __next_builtin__not-found page. Without this,
// route groups can't pick a root layout for URLs that match nothing, so
// 404s render bare.
export default function CatchAll() {
notFound();
}
@@ -78,7 +78,69 @@ function OAuthCallbackInner() {
setError("token_exchange_failed"); setError("token_exchange_failed");
}); });
} else if (state) { } else if (state) {
// Server-side SSO flow - state was stored in encrypted httpOnly cookie // Server-side SSO flow - state was stored in encrypted httpOnly cookie.
// Branch on mobile handoff first: the login page left a marker in
// sessionStorage if it kicked this OAuth dance off for the mobile app.
let mobileRedirectUri: string | null = null;
let mobileState: string | null = null;
try {
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
mobileState = sessionStorage.getItem("mobile_state");
} catch { /* sessionStorage may be unavailable */ }
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
// Drive /api/auth/sso/complete directly so we can read the tokens
// out of the response - loginWithServerSso would consume them and
// wire up the webmail auth store, which isn't useful here. The
// server's mobile-flow branch (keyed on the pending cookie) skips
// the refresh-token cookie write for the same reason.
(async () => {
try {
const res = await fetch("/api/auth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
const data = await res.json();
const serverUrl = data.server_url as string | undefined;
const accessToken = data.access_token as string | undefined;
const tokenEndpoint = data.token_endpoint as string | undefined;
const clientId = data.client_id as string | undefined;
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
setError("token_exchange_failed");
return;
}
const fragment = new URLSearchParams({
flow: "oauth",
server_url: serverUrl,
access_token: accessToken,
token_endpoint: tokenEndpoint,
client_id: clientId,
state: mobileState ?? "",
});
if (typeof data.refresh_token === "string") {
fragment.set("refresh_token", data.refresh_token);
}
if (typeof data.expires_in === "number") {
fragment.set("expires_in", String(data.expires_in));
}
try {
sessionStorage.removeItem("mobile_redirect_uri");
sessionStorage.removeItem("mobile_state");
} catch { /* ignore */ }
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const ssoPrefix = getPathPrefix(params.locale as string); const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state) loginWithServerSso(code, state)
.then((success) => { .then((success) => {
@@ -15,8 +15,9 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
@@ -31,17 +32,21 @@ 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 { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-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";
import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils"; import { getEventStartDate } from "@/lib/calendar-utils";
@@ -56,6 +61,8 @@ 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";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
type PendingScopeAction = type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean } | { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
@@ -68,9 +75,17 @@ function isRecurringEvent(event: CalendarEvent): boolean {
export default function CalendarPage() { export default function CalendarPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
// collapses into a burger-toggled overlay instead of taking inline space.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { const {
@@ -96,6 +111,10 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState<ParsedWebcal | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null); const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null); const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined); const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
@@ -156,10 +175,10 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin(); redirectToLogin();
} else if (client && !supportsCalendar) { } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/"); router.push("/");
} }
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -167,12 +186,95 @@ export default function CalendarPage() {
} }
}, [error]); }, [error]);
const getWebcalProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) => {
if (!account.isConnected) return false;
return connectedClients.get(account.id)?.supportsCalendars() === true;
});
}, []);
const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingWebcalAccountChoice(null);
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [switchAccount]);
const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => {
const protocolAccounts = getWebcalProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingWebcalAccountChoice(pending);
return;
}
if (protocolAccounts.length === 0 && !supportsCalendar) {
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openWebcalForAccount(pending, accountId);
return;
}
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
}, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]);
const closeWebcalActionChoice = useCallback(() => {
setShowWebcalActionChoice(false);
setPendingSubscription(null);
}, []);
const handleImportWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowImportModal(true);
}, []);
const handleSubscribeWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowSubscriptionModal(true);
}, []);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingWebcal = () => {
const pending = consumePendingWebcal();
if (!pending) return;
handleWebcalProtocolRequest(pending);
};
openPendingWebcal();
return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
// Single-account fetch path. The Pro shell aggregates calendars from
// every connected account via [[useProMultiAccountCalendars]] below, so
// skip this fetch there to avoid clobbering the merged list with the
// active client's calendars only.
useEffect(() => {
if (isEmbedded) return;
if (client && !hasFetched.current) { if (client && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchCalendars(client); fetchCalendars(client);
} }
}, [client, fetchCalendars]); }, [client, fetchCalendars, isEmbedded]);
// Auto-refresh iCal subscriptions // Auto-refresh iCal subscriptions
useEffect(() => { useEffect(() => {
@@ -241,10 +343,21 @@ export default function CalendarPage() {
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]); }, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => { useEffect(() => {
if (isEmbedded) return;
if (client && calendars.length > 0 && dateRange) { if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end); fetchEvents(client, dateRange.start, dateRange.end);
} }
}, [client, calendars.length, dateRange, fetchEvents]); }, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
// Pro shell only: aggregate calendars and events from every connected
// account so the sidebar lists them all (and the views render their
// events together). The hook is a no-op outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
isEmbedded ? dateRange?.start ?? null : null,
isEmbedded ? dateRange?.end ?? null : null,
);
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
const navigatePrev = useCallback(() => { const navigatePrev = useCallback(() => {
let next: Date; let next: Date;
@@ -316,6 +429,8 @@ export default function CalendarPage() {
setMobileReturnToMonth(true); setMobileReturnToMonth(true);
setViewMode("day"); setViewMode("day");
} }
// Close the narrow-pane sidebar overlay after the user picks a date.
setNarrowSidebarOpen(false);
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => { const navigateBackToMonth = useCallback(() => {
@@ -466,12 +581,15 @@ export default function CalendarPage() {
}, [events, client]); }, [events, client]);
const refetchCurrentRange = useCallback(async () => { const refetchCurrentRange = useCallback(async () => {
if (!client) return; if (!client || !activeAccountId) return;
const { dateRange: currentRange } = useCalendarStore.getState(); const { dateRange: currentRange } = useCalendarStore.getState();
if (currentRange) { if (!currentRange) return;
await fetchEvents(client, currentRange.start, currentRange.end); if (multiAccountEnabled && accountClients.length > 0) {
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
return;
} }
}, [client, fetchEvents]); await fetchEvents(client, currentRange.start, currentRange.end);
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page. // and refresh calendar data via JMAP instead of reloading the page.
@@ -479,8 +597,11 @@ export default function CalendarPage() {
enabled: isAuthenticated && !!client, enabled: isAuthenticated && !!client,
onRefresh: async () => { onRefresh: async () => {
if (!client) return; if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([ await Promise.all([
fetchCalendars(client), calendarRefresh,
refetchCurrentRange(), refetchCurrentRange(),
refreshAllSubscriptions(client), refreshAllSubscriptions(client),
]); ]);
@@ -955,7 +1076,54 @@ export default function CalendarPage() {
}); });
}, [events, selectedCalendarIds, visibleEvents]); }, [events, selectedCalendarIds, visibleEvents]);
if (!isAuthenticated || !supportsCalendar) return null; const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
<ProtocolAccountPicker
kind="webcal"
operation={pendingWebcalAccountChoice}
accounts={getWebcalProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
onCancel={() => setPendingWebcalAccountChoice(null)}
/>
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={tWebcalAction("title")}
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="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
</Button>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
</div>
</div>
</div>
) : null;
if (!isAuthenticated) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => { const renderView = () => {
if (isLoading && calendars.length === 0) { if (isLoading && calendars.length === 0) {
@@ -1082,9 +1250,11 @@ export default function CalendarPage() {
}; };
return ( return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{/* Left Navigation Rail */} <AppTopBannerSlot />
{!isMobile && ( <div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -1103,15 +1273,31 @@ export default function CalendarPage() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)} )}
{/* Sidebar - full height */} {/* Narrow-pane backdrop: dim and close overlay sidebar */}
{!isMobile && !inlineApp && ( {isNarrow && narrowSidebarOpen && !inlineApp && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{!inlineApp && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3", "border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300" !isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${calSidebarWidth}px` }} style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
> >
<MiniCalendar <MiniCalendar
selectedDate={selectedDate} selectedDate={selectedDate}
@@ -1172,17 +1358,20 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)} onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
<ResizeHandle {!isNarrow && (
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} <ResizeHandle
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResizeEnd={() => { onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
setIsResizing(false); onResizeEnd={() => {
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); setIsResizing(false);
}} localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} }}
/> onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
)}
</> </>
)} )}
@@ -1204,6 +1393,7 @@ export default function CalendarPage() {
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks} enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
<div <div
@@ -1276,7 +1466,7 @@ export default function CalendarPage() {
)} )}
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
{isMobile && ( {isMobile && !isEmbedded && (
<div className="shrink-0"> <div className="shrink-0">
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
@@ -1378,14 +1568,23 @@ export default function CalendarPage() {
<ICalImportModal <ICalImportModal
calendars={calendars} calendars={calendars}
client={client} client={client}
onClose={() => setShowImportModal(false)} initialUrl={pendingSubscription?.url}
onClose={() => {
setShowImportModal(false);
setPendingSubscription(null);
}}
/> />
)} )}
{showSubscriptionModal && client && ( {showSubscriptionModal && client && (
<ICalSubscriptionModal <ICalSubscriptionModal
client={client} client={client}
onClose={() => setShowSubscriptionModal(false)} initialUrl={pendingSubscription?.url}
initialName={pendingSubscription?.name}
onClose={() => {
setShowSubscriptionModal(false);
setPendingSubscription(null);
}}
/> />
)} )}
@@ -1402,6 +1601,8 @@ export default function CalendarPage() {
})()} })()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{renderWebcalAccountPicker()}
{renderWebcalActionChoice()}
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"} actionType={pendingScopeAction?.type || "edit"}
@@ -1435,6 +1636,7 @@ export default function CalendarPage() {
/> />
); );
})()} })()}
</div>
</div> </div>
); );
} }
@@ -2,7 +2,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ArrowLeft, Users } from "lucide-react"; import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -15,17 +17,21 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog"; import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn, generateUUID } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -42,6 +48,7 @@ type View =
export default function ContactsPage() { export default function ContactsPage() {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
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);
@@ -88,11 +95,26 @@ export default function ContactsPage() {
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null); const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null); const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
const [returnToEmail, setReturnToEmail] = useState(false);
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);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
const router = useRouter();
const searchParams = useSearchParams();
// One-shot intent flag: only consume the URL params on the first render that
// has them. After applying, we strip the query so a later refresh or
// re-mount doesn't re-trigger the navigation.
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Panel resize state - sidebar (categories) // Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -129,12 +151,41 @@ export default function ContactsPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Pro shell only: aggregate contacts and address books from every
// connected account so the sidebar lists them all. The hook is a no-op
// outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
useEffect(() => { useEffect(() => {
if (isEmbedded) return;
if (client && supportsSync && !hasFetched.current) { if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchContacts(client); fetchContacts(client);
} }
}, [client, supportsSync, fetchContacts]); }, [client, supportsSync, fetchContacts, isEmbedded]);
// Consume one-shot URL params (set by the mobile recipient popover when no
// sidebar is available) and strip them so a refresh doesn't replay the
// intent. `from=email` flips the mobile back button to `router.back()`.
useEffect(() => {
if (intentAppliedRef.current) return;
const contactId = searchParams.get('contactId');
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
setView('create');
}
router.replace('/contacts');
}, [searchParams, router, setSelectedContact]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page. // and refresh contacts via JMAP instead of reloading the page.
@@ -142,6 +193,17 @@ export default function ContactsPage() {
enabled: isAuthenticated && !!client && supportsSync, enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => { onRefresh: async () => {
if (!client) return; if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
await Promise.all([
fetchAllAccountsAddressBooks(accountClients, activeId),
fetchAllAccountsContacts(accountClients, activeId),
]);
return;
}
}
await fetchContacts(client); await fetchContacts(client);
}, },
}); });
@@ -193,6 +255,7 @@ export default function ContactsPage() {
} else { } else {
setSelectedGroupId(null); setSelectedGroupId(null);
} }
setNarrowSidebarOpen(false);
}, [clearSelection]); }, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -335,8 +398,14 @@ export default function ContactsPage() {
toast.success(t("toast.created")); toast.success(t("toast.created"));
} }
setDefaultBookIdForCreate(undefined); setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
router.back();
return;
}
setView("list"); setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]); }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => { const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return; if (!selectedContact) return;
@@ -353,6 +422,14 @@ export default function ContactsPage() {
const handleCancel = () => { const handleCancel = () => {
setDefaultBookIdForCreate(undefined); setDefaultBookIdForCreate(undefined);
// Came from email → cancel returns to the email instead of the contact list.
if (returnToEmail && view === "create") {
setCreatePrefill(undefined);
setReturnToEmail(false);
router.back();
return;
}
if (view === "create") setCreatePrefill(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") {
@@ -524,7 +601,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -640,18 +717,38 @@ export default function ContactsPage() {
} }
}; };
if (!contactsEnabled) {
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
</div>
</div>
);
}
const showListPanel = !isMobile || view === "list"; const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list"; const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => { const mobileBackToList = () => {
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
router.back();
return;
}
setView("list"); setView("list");
clearSelection(); clearSelection();
}; };
return ( return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{/* Navigation Rail - desktop only */} <AppTopBannerSlot />
{!isMobile && ( <div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -670,18 +767,33 @@ export default function ContactsPage() {
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{showListPanel && ( {showListPanel && (
<> <>
{/* Panel 1: Categories sidebar */} {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{!isMobile && ( {(!isMobile || isNarrow) && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border flex flex-col flex-shrink-0", "border-r border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300" !isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${sidebarWidth}px` }} style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
> >
<ContactsSidebar <ContactsSidebar
groups={groups} groups={groups}
@@ -718,17 +830,20 @@ export default function ContactsPage() {
} }
} : undefined} } : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)} onRenameKeyword={(kw) => setRenamingKeyword(kw)}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
<ResizeHandle {!isNarrow && (
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} <ResizeHandle
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResizeEnd={() => { onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
setIsSidebarResizing(false); onResizeEnd={() => {
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); setIsSidebarResizing(false);
}} localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} }}
/> onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</> </>
)} )}
@@ -761,6 +876,7 @@ export default function ContactsPage() {
onEditContact={handleEditContact} onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact} onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup} onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
</div> </div>
@@ -790,7 +906,7 @@ export default function ContactsPage() {
className="touch-manipulation" className="touch-manipulation"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_contacts")} {returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button> </Button>
</div> </div>
)} )}
@@ -801,7 +917,7 @@ export default function ContactsPage() {
)} )}
</div> </div>
{isMobile && ( {isMobile && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -882,6 +998,7 @@ export default function ContactsPage() {
/> />
); );
})()} })()}
</div>
</div> </div>
); );
} }
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -16,6 +17,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
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 { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
@@ -24,6 +26,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
@@ -31,6 +34,9 @@ export default function FilesPage() {
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
const accounts = useAccountStore((s) => s.accounts);
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 { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
@@ -83,6 +89,7 @@ export default function FilesPage() {
} = useFileStore(); } = useFileStore();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isEmbedded = useIsEmbedded();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout); const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false); const hasFetched = useRef(false);
@@ -127,13 +134,18 @@ export default function FilesPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Initialize JMAP files client // Initialize JMAP files client. In the Pro shell, all connected accounts
// are surfaced as top-level folders at the root, so we *don't* auto-attach
// to the active account - the user picks one explicitly.
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) { if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true; hasFetched.current = true;
initClient(client); if (isEmbedded) {
useFileStore.getState().clearClient();
} else {
initClient(client, activeAccountId);
} }
}, [isAuthenticated, client, initClient]); }, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page. // and refresh files via JMAP instead of reloading the page.
@@ -157,6 +169,17 @@ export default function FilesPage() {
}, [storeClient, supportsFiles, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => { const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
// account's filesystem root" via a sentinel, distinguishing it from a
// Home click (which detaches the account and returns to the picker).
if (resourceId === '__account_root__') {
void navigate(null);
return;
}
if (isEmbedded && path === '/' && resourceId === undefined) {
useFileStore.getState().clearClient();
return;
}
if (resourceId !== undefined) { if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder) // Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || ''); navigate(resourceId, path.split('/').pop() || '');
@@ -164,7 +187,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files) // Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path); navigateByPath(path);
} }
}, [navigate, navigateByPath]); }, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => { const handleCreateFolder = useCallback(async (name: string) => {
try { try {
@@ -371,11 +394,45 @@ export default function FilesPage() {
setShowDetails(v => !v); setShowDetails(v => !v);
}, []); }, []);
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
const accountFolders = isEmbedded
? accounts
.filter((a) => a.isConnected)
.map((a) => ({
accountId: a.id,
label: a.label || a.email,
email: a.email,
avatarColor: a.avatarColor,
}))
: [];
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
const currentAccountLabel = isEmbedded && currentFilesAccountId
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|| null)
: null;
const handleSelectAccount = useCallback((accountId: string) => {
const nextClient = getClientForAccount(accountId);
if (!nextClient) return;
const store = useFileStore.getState();
store.initClient(nextClient, accountId);
// Reset supportsFiles so the existing checkSupport effect re-runs for
// the freshly-attached client and triggers the initial navigate(null).
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{!isMobile && ( <AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -396,7 +453,7 @@ export default function FilesPage() {
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && ( {folderLayout !== "sidebar" && !isEmbedded && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}> <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -474,6 +531,10 @@ export default function FilesPage() {
showDetails={showDetails} showDetails={showDetails}
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
/> />
</div> </div>
)} )}
@@ -481,7 +542,7 @@ export default function FilesPage() {
</div> </div>
</div> </div>
{isMobile && ( {isMobile && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -514,6 +575,7 @@ export default function FilesPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div>
</div> </div>
); );
} }
@@ -5,6 +5,10 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider"; import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -32,7 +36,12 @@ export default async function LocaleLayout({
<RateLimitToastProvider> <RateLimitToastProvider>
<EmbeddedBridgeProvider> <EmbeddedBridgeProvider>
<TourProvider> <TourProvider>
{children} <ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
{children}
<PluginDialogHost />
<PluginConsentDialog />
</ProtocolLaunchHandlerProvider>
</TourProvider> </TourProvider>
</EmbeddedBridgeProvider> </EmbeddedBridgeProvider>
</RateLimitToastProvider> </RateLimitToastProvider>
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
import { useUpdateStore, selectBanner } from "@/stores/update-store"; import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
@@ -109,15 +108,32 @@ function VersionBadge() {
); );
} }
// Only redirect targets matching this scheme are honored by the mobile
// handoff path. Without the check the login page becomes an open redirector
// that funnels password and token material to any caller-supplied URL.
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("login"); const t = useTranslations("login");
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account"; const isAddAccountMode = searchParams.get("mode") === "add-account";
// When the mobile app launches the webmail in a browser tab it tacks on
// these params. We grab them once at mount and stash them in a ref so any
// login path that completes (password or OAuth) can hand control back to
// the app instead of routing into /mail.
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
const rawMobileState = searchParams.get("mobile_state") ?? "";
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
? rawMobileRedirectUri
: "";
const mobileState = mobileRedirectUri ? rawMobileState : "";
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -160,6 +176,9 @@ export default function LoginPage() {
const totpInputRef = useRef<HTMLInputElement>(null); const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null); const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null); const themeMenuRef = useRef<HTMLDivElement>(null);
// Captured by handleSubmit when in mobile handoff mode; consumed by the
// isAuthenticated effect to build the deep-link fragment.
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
useEffect(() => { useEffect(() => {
initializeTheme(); initializeTheme();
@@ -239,6 +258,19 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (isAuthenticated && !isAddAccountMode) { if (isAuthenticated && !isAddAccountMode) {
// Mobile handoff: the password path completes here once the auth store
// flips isAuthenticated. Hand the verified credentials back to the
// mobile app instead of pushing to /mail. handleSubmit captured the
// values needed for the fragment.
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
const fragment = new URLSearchParams({
flow: "password",
...mobileHandoffPayloadRef.current,
state: mobileState,
});
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
return;
}
let redirectTo = '/'; let redirectTo = '/';
try { try {
const saved = sessionStorage.getItem('redirect_after_login'); const saved = sessionStorage.getItem('redirect_after_login');
@@ -249,7 +281,7 @@ export default function LoginPage() {
} catch { /* ignore */ } } catch { /* ignore */ }
router.push(redirectTo); router.push(redirectTo);
} }
}, [isAuthenticated, router, isAddAccountMode]); }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => { useEffect(() => {
clearError(); clearError();
@@ -317,6 +349,16 @@ export default function LoginPage() {
try { try {
const prefix = getPathPrefix(params.locale as string); const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
// In mobile-handoff mode the callback page needs to know it should
// redirect into the app rather than into /mail. Stash the params in
// sessionStorage so the same-tab callback can read them - the SSO
// pending cookie carries the authoritative copy server-side too.
if (isMobileHandoff) {
try {
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
sessionStorage.setItem("mobile_state", mobileState);
} catch { /* sessionStorage unavailable */ }
}
const res = await apiFetch('/api/auth/sso/start', { const res = await apiFetch('/api/auth/sso/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -325,6 +367,9 @@ export default function LoginPage() {
redirect_uri: redirectUri, redirect_uri: redirectUri,
locale: params.locale, locale: params.locale,
server_id: selectedServer?.id, server_id: selectedServer?.id,
...(isMobileHandoff
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
: {}),
}), }),
}); });
@@ -351,7 +396,7 @@ export default function LoginPage() {
} catch { } catch {
setOauthLoading(false); setOauthLoading(false);
} }
}, [params.locale, selectedServer?.id]); }, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => { useEffect(() => {
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
@@ -493,6 +538,15 @@ export default function LoginPage() {
const handleOAuthLogin = async () => { const handleOAuthLogin = async () => {
if (!oauthMetadata || !effectiveOauthClientId) return; if (!oauthMetadata || !effectiveOauthClientId) return;
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
// tokens would land in sessionStorage on the webmail origin and the
// mobile app couldn't read them. Route through the server-side SSO
// path instead, which has the mobile-aware /api/auth/sso/complete
// branch.
if (isMobileHandoff) {
await startServerSideSso();
return;
}
setOauthLoading(true); setOauthLoading(true);
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
@@ -532,7 +586,7 @@ export default function LoginPage() {
authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", effectiveOauthClientId); authUrl.searchParams.set("client_id", effectiveOauthClientId);
authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", OAUTH_SCOPES); authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
authUrl.searchParams.set("state", state); authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256"); authUrl.searchParams.set("code_challenge_method", "S256");
@@ -547,6 +601,16 @@ export default function LoginPage() {
// when the admin hasn't configured a server list. // when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// Capture before login() so the isAuthenticated effect can build the
// deep-link fragment with values the user actually typed (formData may
// be cleared by the auth store on success).
if (isMobileHandoff) {
mobileHandoffPayloadRef.current = {
server_url: effectiveServerUrl,
username: formData.username,
password: formData.password,
};
}
const success = await login( const success = await login(
effectiveServerUrl, effectiveServerUrl,
formData.username, formData.username,
@@ -557,7 +621,15 @@ export default function LoginPage() {
if (success) { if (success) {
saveUsername(formData.username); saveUsername(formData.username);
if (isMobileHandoff) {
// The isAuthenticated effect handles the redirect; nothing else to
// do here. Don't push to / - that would race the deep link.
return;
}
router.push('/'); router.push('/');
} else if (isMobileHandoff) {
// Stale payload should never feed into a later retry's redirect.
mobileHandoffPayloadRef.current = null;
} }
}; };
@@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list";
import { EmailViewer } from "@/components/email/email-viewer"; import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer"; import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header"; import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
@@ -48,6 +49,9 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync"; import { useIdentitySync } from "@/hooks/use-identity-sync";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProTabStore } from "@/stores/pro-tab-store";
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
import { Input } from "@/components/ui/input"; 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";
@@ -59,9 +63,16 @@ 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 { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection"; import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types";
export default function Home() { export default function Home() {
@@ -74,6 +85,10 @@ export default function Home() {
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0); const [composerSessionId, setComposerSessionId] = useState(0);
// Plugin-resolved quote header for the next reply/forward composer open.
// Cleared on close so a subsequent "compose new" doesn't reuse stale state.
const [composerQuoteHeader, setComposerQuoteHeader] = useState<QuoteHeader | null>(null);
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -89,8 +104,10 @@ export default function Home() {
const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
useIdentitySync(); useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
@@ -203,6 +220,7 @@ export default function Home() {
// Mobile/tablet responsive hooks // Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection(); const { isMobile, isTablet } = useDeviceDetection();
const isEmbedded = useIsEmbedded();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
const { const {
emails, emails,
@@ -264,8 +282,16 @@ export default function Home() {
batchMarkAsRead, batchMarkAsRead,
batchMarkAsSpam, batchMarkAsSpam,
batchUndoSpam, batchUndoSpam,
accountMailboxes,
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
} = useEmailStore(); } = useEmailStore();
// Pro shell: populate per-account mailbox cache so the sidebar can render
// every connected account Thunderbird-style.
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const accounts = useAccountStore((s) => s.accounts); const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo( const connectedAccountsSignature = useMemo(
@@ -308,6 +334,13 @@ export default function Home() {
[], [],
); );
const getMailtoProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) =>
account.isConnected && connectedClients.has(account.id)
);
}, []);
// Browser back / forward integration. The restore handler reads the // Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on // latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale). // every render (and so the popstate listener is never stale).
@@ -608,6 +641,65 @@ export default function Home() {
document.title = title; document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
// When this page is rendered inside the Pro shell as the Mail tab body,
// we hoist every "show composer" intent into its own Pro tab and reset
// the in-page state so the inline composer never appears in the Mail tab.
// This makes the Pro composer behave like Thunderbird's pop-out window.
useEffect(() => {
if (!isEmbedded || !showComposer) return;
const replyTo = selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined;
const effectiveMode = pendingDraft?.mode ?? composerMode;
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
let title = t('email_composer.new_message');
if (baseSubject) {
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
} else if (effectiveMode === 'forward') {
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
} else {
title = baseSubject;
}
}
useProTabStore.getState().openComposeTab({
sessionId: composerSessionId + 1,
mode: effectiveMode,
replyTo,
initialDraftText: composerDraftText,
initialData: pendingDraft,
sourceEmailId: selectedEmail?.id ?? null,
title,
});
setComposerSessionId((s) => s + 1);
setShowComposer(false);
setComposerDraftText("");
setPendingDraft(null);
setComposerQuoteHeader(null);
// We only react to the rising edge of `showComposer` here; the other
// variables read above are captured-but-stale-safe because the next
// open will fire a fresh effect with new values.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEmbedded, showComposer]);
// Check auth on mount skip when already authenticated so that navigating // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null, // between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav. // isLoading: true }` reset, which was flashing the spinner on every nav.
@@ -651,6 +743,74 @@ export default function Home() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
const openMailtoDraft = useCallback((pending: ParsedMailto) => {
const body = useSettingsStore.getState().plainTextMode
? pending.body
: plainTextToComposerBody(pending.body);
if (showComposer) {
suppressComposerStateSaveSessionRef.current = composerSessionId;
}
setComposerSessionId((id) => id + 1);
setPendingDraft({
to: pending.to.join(", "),
cc: pending.cc.join(", "),
bcc: pending.bcc.join(", "),
subject: pending.subject,
body,
showCc: pending.cc.length > 0,
showBcc: pending.bcc.length > 0,
selectedIdentityId: null,
subAddressTag: "",
mode: "compose",
draftId: null,
});
setComposerMode("compose");
setShowComposer(true);
if (isMobile) setActiveView("viewer");
}, [composerSessionId, isMobile, setActiveView, showComposer]);
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingMailtoAccountChoice(null);
openMailtoDraft(pending);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [openMailtoDraft, switchAccount]);
const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingMailtoAccountChoice(pending);
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openMailtoForAccount(pending, accountId);
return;
}
openMailtoDraft(pending);
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingMailto = () => {
const pending = consumePendingMailto();
if (pending) handleMailtoProtocolRequest(pending);
};
openPendingMailto();
return subscribeToPendingMailto(openPendingMailto);
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
// Fallback fetch for paths that didn't go through login()'s prefetch // Fallback fetch for paths that didn't go through login()'s prefetch
// (notably checkAuth on page refresh). The prefetch in auth-store/login() // (notably checkAuth on page refresh). The prefetch in auth-store/login()
// populates mailboxes before this effect first runs, so on the post-login // populates mailboxes before this effect first runs, so on the post-login
@@ -724,15 +884,17 @@ export default function Home() {
// Keep unified mailbox counts in sync when the feature is enabled and more // Keep unified mailbox counts in sync when the feature is enabled and more
// than one account is connected. Runs whenever the set of connected accounts // than one account is connected. Runs whenever the set of connected accounts
// or the primary account's mailboxes change (a proxy for "something worth // or the primary account's mailboxes change (a proxy for "something worth
// recounting happened"). // recounting happened"). The Pro shell always renders the unified mailbox
// regardless of the user setting, so refresh when embedded too.
useEffect(() => { useEffect(() => {
if (!enableUnifiedMailbox || !isAuthenticated || !client) return; if (!enableUnifiedMailbox && !isEmbedded) return;
if (!isAuthenticated || !client) return;
const built = buildUnifiedAccounts(); const built = buildUnifiedAccounts();
if (built.length < 2) return; if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => { populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(populated); refreshUnifiedCounts(populated);
}); });
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); }, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back // System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or // here with `?email=<id>` (specific email it built the toast from) or
@@ -917,6 +1079,50 @@ export default function Home() {
} }
}; };
// Build the quote header for a reply/forward open, running it through the
// emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores
// the result in composerQuoteHeader; the render site spreads it into
// EmailComposer.replyTo. Errors fall back to the composer's built-in
// header (state set to null).
const prepareComposerQuoteHeader = useCallback(async (
email: Email | null,
mode: 'reply' | 'replyAll' | 'forward',
) => {
if (!email) { setComposerQuoteHeader(null); return; }
try {
const replyTargets = (email.replyTo?.length
? email.replyTo
: email.from ?? []).filter(r => r.email).map(r => r.email!);
const newTo = mode === 'reply'
? replyTargets
: mode === 'replyAll'
? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)]
: [];
const newCc = mode === 'replyAll'
? (email.cc ?? []).filter(r => r.email).map(r => r.email!)
: [];
const header = await buildQuoteHeader({
mode,
email: {
from: email.from,
to: email.to,
cc: email.cc,
subject: email.subject,
receivedAt: email.receivedAt,
},
newTo,
newCc,
locale: useLocaleStore.getState().locale,
timeFormat: useSettingsStore.getState().timeFormat,
unknownLabel: tCommon('unknown'),
});
setComposerQuoteHeader(header);
} catch (err) {
console.warn('[quote-header] plugin transform failed; using default', err);
setComposerQuoteHeader(null);
}
}, [tCommon]);
const handleReply = async (draftText?: string) => { const handleReply = async (draftText?: string) => {
if (selectedEmail) { if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({ const ok = await emailHooks.onBeforeReply.intercept({
@@ -925,6 +1131,9 @@ export default function Home() {
mode: 'reply' as const, mode: 'reply' as const,
}); });
if (!ok) return; if (!ok) return;
await prepareComposerQuoteHeader(selectedEmail, 'reply');
} else {
setComposerQuoteHeader(null);
} }
setComposerDraftText(draftText || ""); setComposerDraftText(draftText || "");
setComposerMode('reply'); setComposerMode('reply');
@@ -992,6 +1201,9 @@ export default function Home() {
mode: 'reply-all' as const, mode: 'reply-all' as const,
}); });
if (!ok) return; if (!ok) return;
await prepareComposerQuoteHeader(selectedEmail, 'replyAll');
} else {
setComposerQuoteHeader(null);
} }
setComposerMode('replyAll'); setComposerMode('replyAll');
setShowComposer(true); setShowComposer(true);
@@ -1006,6 +1218,9 @@ export default function Home() {
mode: 'forward' as const, mode: 'forward' as const,
}); });
if (!ok) return; if (!ok) return;
await prepareComposerQuoteHeader(selectedEmail, 'forward');
} else {
setComposerQuoteHeader(null);
} }
setComposerMode('forward'); setComposerMode('forward');
setShowComposer(true); setShowComposer(true);
@@ -1214,6 +1429,35 @@ export default function Home() {
} }
}; };
// Whenever the global active account changes, drop any non-active viewing
// override so we don't leave the email list pointed at a now-stale id.
useEffect(() => {
if (viewingAccountId && viewingAccountId === activeAccountId) {
setViewingAccount(null);
}
}, [activeAccountId, viewingAccountId, setViewingAccount]);
// Pro sidebar: user clicked a folder under a specific account group.
// accountId === null means the active account; non-null means a viewing
// override that fetches via that account's JMAP client.
const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => {
const viewingClient = accountId
? useAuthStore.getState().getClientForAccount(accountId) ?? client
: client;
selectAccountMailbox(accountId, mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
if (viewingClient) {
await fetchEmails(viewingClient, mailboxId);
}
};
const handleMailboxSelect = async (mailboxId: string) => { const handleMailboxSelect = async (mailboxId: string) => {
if (isUnifiedMailboxId(mailboxId)) { if (isUnifiedMailboxId(mailboxId)) {
const role = UNIFIED_ROLE_BY_ID[mailboxId]; const role = UNIFIED_ROLE_BY_ID[mailboxId];
@@ -1552,7 +1796,6 @@ export default function Home() {
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query); setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) { if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client); await advancedSearch(client);
@@ -1564,14 +1807,25 @@ export default function Home() {
const handleClearSearch = async () => { const handleClearSearch = async () => {
setSearchQuery(""); setSearchQuery("");
clearSearchFilters(); clearSearchFilters();
if (client && selectedMailbox) { if (!client) return;
// In unified view the active "mailbox" is a virtual role, so refresh via
// the unified fan-out instead of fetchEmails.
if (isUnifiedView) {
const role = useEmailStore.getState().unifiedRole;
if (role) {
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role);
}
return;
}
if (selectedMailbox) {
await fetchEmails(client, selectedMailbox); await fetchEmails(client, selectedMailbox);
} }
}; };
const handleAdvancedSearch = async () => { const handleAdvancedSearch = async () => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client); await advancedSearch(client);
}; };
@@ -1581,9 +1835,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current); clearTimeout(advancedSearchDebounceRef.current);
} }
advancedSearchDebounceRef.current = setTimeout(() => { advancedSearchDebounceRef.current = setTimeout(() => {
if (client && !isUnifiedView) advancedSearch(client); if (client) advancedSearch(client);
}, 300); }, 300);
}, [client, advancedSearch, isUnifiedView]); }, [client, advancedSearch]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1659,7 +1913,9 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary // Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias). // when the reply-from lives on the same identity but a different alias).
const finalBody = appendPlainTextSignature(body, sendingIdentity); const finalBody = appendPlainTextSignature(body, sendingIdentity, {
separator: useSettingsStore.getState().signatureSeparatorEnabled,
});
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
@@ -1717,7 +1973,7 @@ export default function Home() {
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent; const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching // Handle email selection with mobile view switching
@@ -1832,22 +2088,25 @@ export default function Home() {
}; };
// Handle reply from conversation view // Handle reply from conversation view
const handleConversationReply = (email: Email) => { const handleConversationReply = async (email: Email) => {
selectEmail(email); selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
setComposerMode('reply'); setComposerMode('reply');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
const handleConversationReplyAll = (email: Email) => { const handleConversationReplyAll = async (email: Email) => {
selectEmail(email); selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
setComposerMode('replyAll'); setComposerMode('replyAll');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
const handleConversationForward = (email: Email) => { const handleConversationForward = async (email: Email) => {
selectEmail(email); selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
setComposerMode('forward'); setComposerMode('forward');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
@@ -1875,7 +2134,8 @@ export default function Home() {
return ( return (
<DragDropProvider> <DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden"> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
{isRateLimited && rateLimitSecondsLeft !== null && ( {isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0"> <div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
@@ -1890,8 +2150,8 @@ export default function Home() {
</div> </div>
)} )}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Desktop Navigation Rail */} {/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
{!isMobile && !isTablet && ( {!isMobile && !isTablet && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -1911,25 +2171,44 @@ export default function Home() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)} )}
{/* Mobile/Tablet Sidebar Overlay Backdrop */} {/* Mobile/Tablet Sidebar Overlay Backdrop.
When embedded in a Pro pane the viewport is desktop-wide, so the
`lg:hidden` viewport-variant alone wouldn't gate this overlay;
scope to the pane via `absolute` so the backdrop stays inside
the pane instead of covering the whole window. */}
{(isMobile || isTablet) && sidebarOpen && !inlineApp && ( {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
<div <div
className="fixed inset-0 bg-black/50 z-40 lg:hidden" className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed lg:hidden"
)}
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
)} )}
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */} {/* Sidebar - overlay on mobile/tablet, in-flow on desktop.
When embedded, overlay-mode is driven by pane-aware JS rather
than viewport-variants (which still see the full window). */}
<div <div
className={cn( className={cn(
"flex-shrink-0 h-full z-50", "flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300", !isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay isEmbedded
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72", ? (isMobile || isTablet
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out", ? cn(
!sidebarOpen && "max-lg:-translate-x-full", "absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
// Desktop: normal flow "transform transition-transform duration-300 ease-in-out",
"lg:relative lg:translate-x-0", !sidebarOpen && "-translate-x-full"
)
: "relative translate-x-0")
: cn(
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
"lg:relative lg:translate-x-0"
),
inlineApp && "hidden" inlineApp && "hidden"
)} )}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined} style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
@@ -1961,6 +2240,10 @@ export default function Home() {
} }
}} }}
onSidebarClose={() => setSidebarOpen(false)} onSidebarClose={() => setSidebarOpen(false)}
multiAccountMode={isEmbedded}
accountMailboxes={accountMailboxes}
viewingAccountId={viewingAccountId}
onAccountMailboxSelect={handleAccountMailboxSelect}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@@ -2053,8 +2336,6 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")} className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input data-search-input
data-tour="search-input" data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -2070,15 +2351,13 @@ export default function Home() {
<button <button
type="button" type="button"
onClick={toggleAdvancedSearch} onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn( className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors", "relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0 isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")} title={t("advanced_search.toggle_filters")}
> >
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && ( {!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -2237,6 +2516,14 @@ export default function Home() {
selectedEmailId={selectedEmail?.id} selectedEmailId={selectedEmail?.id}
isLoading={isLoading} isLoading={isLoading}
onEmailSelect={handleEmailSelect} onEmailSelect={handleEmailSelect}
onEmailDoubleClick={isEmbedded ? ((email) => {
useProTabStore.getState().openEmailTab({
accountId: email.accountId ?? '',
emailId: email.id,
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
});
}) : undefined}
onOpenConversation={handleOpenConversation} onOpenConversation={handleOpenConversation}
// Context menu handlers // Context menu handlers
onReply={(email) => { onReply={(email) => {
@@ -2309,7 +2596,7 @@ export default function Home() {
</div> </div>
{/* Email list resize handle (desktop only) */} {/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && ( {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -2334,7 +2621,7 @@ export default function Home() {
isHorizontalMailLayout ? "min-h-0" : "h-full", isHorizontalMailLayout ? "min-h-0" : "h-full",
// Mobile: full screen overlay when active // Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30", "max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full", "max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
isMobile && activeView !== "viewer" && "max-md:hidden", isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative // Tablet/Desktop: relative
"md:relative", "md:relative",
@@ -2342,13 +2629,16 @@ export default function Home() {
shouldHideHorizontalViewerPane && "md:hidden" shouldHideHorizontalViewerPane && "md:hidden"
)} )}
> >
{/* Inline Composer - shown in viewer pane */} {/* Inline Composer - shown in viewer pane.
{showComposer ? ( In Pro/embedded mode the composer is hoisted into its own
Pro tab (see the effect below), so we never render it inline. */}
{(showComposer && !isEmbedded) ? (
<ErrorBoundary <ErrorBoundary
fallback={ComposerErrorFallback} fallback={ComposerErrorFallback}
onReset={() => { onReset={() => {
setShowComposer(false); setShowComposer(false);
setComposerMode('compose'); setComposerMode('compose');
setComposerQuoteHeader(null);
}} }}
> >
<EmailComposer <EmailComposer
@@ -2368,10 +2658,19 @@ export default function Home() {
messageId: selectedEmail.messageId, messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo, inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references, references: selectedEmail.references,
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
initialData={pendingDraft} initialData={pendingDraft}
onSaveState={(data) => setPendingDraft(data)} onSaveState={(data) => {
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
suppressComposerStateSaveSessionRef.current = null;
return;
}
setPendingDraft(data);
}}
onSend={async (data) => { onSend={async (data) => {
await handleEmailSend(data); await handleEmailSend(data);
setPendingDraft(null); setPendingDraft(null);
@@ -2381,6 +2680,7 @@ export default function Home() {
setComposerMode('compose'); setComposerMode('compose');
setComposerDraftText(""); setComposerDraftText("");
setPendingDraft(null); setPendingDraft(null);
setComposerQuoteHeader(null);
if (isMobile) { if (isMobile) {
setActiveView('list'); setActiveView('list');
} }
@@ -2494,8 +2794,8 @@ export default function Home() {
</div> </div>
</div> </div>
{/* Bottom Navigation - mobile and tablet */} {/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
{(isMobile || isTablet) && activeView !== "viewer" && ( {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -2526,6 +2826,17 @@ export default function Home() {
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" /> <div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{pendingMailtoAccountChoice && (
<ProtocolAccountPicker
kind="mailto"
operation={pendingMailtoAccountChoice}
accounts={getMailtoProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
onCancel={() => setPendingMailtoAccountChoice(null)}
/>
)}
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} /> <PromptDialog {...promptDialogProps} />
<TotpReauthDialog /> <TotpReauthDialog />
+402
View File
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
import FilesPage from "@/app/(main)/[locale]/files/page";
import SettingsPage from "@/app/(main)/[locale]/settings/page";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
mail: MailPage,
calendar: CalendarPage,
contacts: ContactsPage,
files: FilesPage,
settings: SettingsPage,
};
type DropTarget = 'left' | 'right' | null;
function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'compose' && tab.composeData) {
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
}
if (tab.kind === 'email' && tab.emailData) {
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
}
const Component = APP_TAB_COMPONENTS[tab.kind];
return Component ? <Component /> : null;
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
activeTabId: string | null;
loadedTabIds: string[];
onPaneFocus: (paneId: ProPaneId) => void;
isFocused: boolean;
}
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
// viewport - and inner pages collapse to their mobile/tablet layouts when
// the pane is narrow.
const [paneWidth, setPaneWidth] = useState<number | null>(null);
useEffect(() => {
const el = paneRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const initialRect = el.getBoundingClientRect();
if (initialRect.width > 0) setPaneWidth(initialRect.width);
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const w = entry.contentRect.width;
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div
ref={paneRef}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
</div>
);
}
export default function ProHome() {
const t = useTranslations();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
const [initialCheckDone, setInitialCheckDone] = useState(
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const {
showAppsModal,
inlineApp,
loadedApps,
handleManageApps,
handleInlineApp,
closeInlineApp,
closeAppsModal,
} = useSidebarApps();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);
const logout = useAuthStore((s) => s.logout);
const checkAuth = useAuthStore((s) => s.checkAuth);
const authLoading = useAuthStore((s) => s.isLoading);
const quota = useEmailStore((s) => s.quota);
const isPushConnected = useEmailStore((s) => s.isPushConnected);
const proInterface = useSettingsStore((s) => s.proInterface);
const tabs = useProTabStore((s) => s.tabs);
const activeMainTabId = useProTabStore((s) => s.activeTabId);
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
const splitOrientation = useProTabStore((s) => s.splitOrientation);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
/** Whether the split pane visually renders before (true) or after (false) main. */
const [splitLeading, setSplitLeading] = useState(false);
// Auth bootstrap (mirrors standard page)
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
useEffect(() => {
if (!initialCheckDone || typeof window === "undefined") return;
// Pro is desktop-only, and only used when the user has explicitly
// enabled it. If either precondition stops holding, hand the user back
// to the standard shell.
if (isMobile || isTablet || !proInterface) {
window.location.replace("/");
}
}, [initialCheckDone, isMobile, isTablet, proInterface]);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
const focusedActiveTab = useMemo(() => {
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
return tabs.find((t) => t.id === id) ?? null;
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
openTab(itemId);
return true;
};
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
focusedActiveTab && (
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|| focusedActiveTab.kind === 'settings'
) ? focusedActiveTab.kind : null;
const isSplit = splitOrientation !== null && splitTabs.length > 0;
// ---- Body-level drop targets ----
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
const rect = e.currentTarget.getBoundingClientRect();
const xFrac = (e.clientX - rect.left) / rect.width;
return xFrac < 0.5 ? 'left' : 'right';
};
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
if (!target || !isSplit) return null;
const leftIsSplit = splitLeading;
if (target === 'left') return leftIsSplit ? 'split' : 'main';
return leftIsSplit ? 'main' : 'split';
};
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const next = computeDropTarget(e);
if (next !== splitDropTarget) setSplitDropTarget(next);
};
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setSplitDropTarget(null);
};
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const target = computeDropTarget(e);
setSplitDropTarget(null);
setIsTabDragging(false);
if (!target) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
if (isSplit) {
// Move tab to whichever pane occupies the dropped side.
const destPane = targetPaneFromDrop(target);
if (destPane) moveTabToPane(draggedId, destPane);
return;
}
// Create a new side-by-side split. `splitLeading` controls which side
// visually hosts the split pane.
moveTabToPane(draggedId, 'split', 'vertical');
setSplitLeading(target === 'left');
};
// Loading state (matches standard page exactly)
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
if (!isDesktop) return null;
// Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at
// index 0 - repurposing the *split* pane's instance into the main pane,
// which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed).
const mainPane = (
<Pane
key="pane-main"
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
);
const splitPane = isSplit ? (
<Pane
key="pane-split"
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'split'}
/>
) : null;
const splitDivider = isSplit ? (
<div
key="pane-divider"
aria-hidden="true"
className="flex-shrink-0 w-px bg-transparent"
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
/>
) : null;
// Drop-zone overlay: a single half-body preview of where the dragged tab
// would land. The whole body is always a drop target (the entire surface
// maps to one of the four sides), so we only render the active side.
const dropZone = isTabDragging && splitDropTarget ? (
<DropZone side={splitDropTarget} />
) : null;
return (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className="flex flex-1 overflow-hidden">
{/* Leftmost Navigation Rail - identical to the standard layout */}
<div
className="w-14 bg-secondary flex flex-col flex-shrink-0"
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onShowShortcuts={() => setShowShortcutsModal(true)}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
onNavigate={handleRailNavigate}
activeItemId={railActiveItemId}
/>
</div>
{inlineApp && (
<InlineAppView
apps={loadedApps}
activeAppId={inlineApp.id}
onClose={closeInlineApp}
className="flex-1"
/>
)}
{!inlineApp && (
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{/* Single, unified tab bar above both panes. */}
<ProTabBar
tabs={tabs}
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
/>
{/* Panes container - accepts body drops for split/move. */}
<div
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{dropZone}
</div>
</div>
)}
</div>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
{showAppsModal && (
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
)}
</div>
</EmbeddedContext.Provider>
);
}
function DropZone({ side }: { side: 'left' | 'right' }) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
side === 'left' ? "left-0" : "right-0",
)}
/>
);
}
@@ -26,6 +26,7 @@ import {
Bell, Bell,
Puzzle, Puzzle,
LayoutGrid, LayoutGrid,
Link as LinkIcon,
BookOpen, BookOpen,
PenLine, PenLine,
EyeOff, EyeOff,
@@ -38,6 +39,7 @@ import {
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 { AppearanceSettings } from '@/components/settings/appearance-settings'; import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
import { LayoutSettings } from '@/components/settings/layout-settings'; import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings'; import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings'; import { ReadingSettings } from '@/components/settings/reading-settings';
@@ -63,6 +65,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
import { NotificationSettings } from '@/components/settings/notification-settings'; import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings'; import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store'; import { usePluginStore } from '@/stores/plugin-store';
@@ -73,6 +76,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view'; import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps'; import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { ResizeHandle } from '@/components/layout/resize-handle'; import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
@@ -98,6 +102,7 @@ type Tab =
| 'calendar' | 'calendar'
| 'contacts' | 'contacts'
| 'files' | 'files'
| 'protocol_handlers'
| 'sidebar_apps' | 'sidebar_apps'
| 'about_data' | 'about_data'
| 'themes' | 'themes'
@@ -133,6 +138,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
calendar: Calendar, calendar: Calendar,
contacts: BookUser, contacts: BookUser,
files: HardDrive, files: HardDrive,
protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose, sidebar_apps: PanelLeftClose,
about_data: Info, about_data: Info,
themes: Palette, themes: Palette,
@@ -155,6 +161,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.account.email', 'settings.account.email',
'settings.account.server', 'settings.account.server',
'settings.account.storage', 'settings.account.storage',
'settings.account.accounts',
], ],
language: ['settings.appearance.language'], language: ['settings.appearance.language'],
notifications: ['settings.notifications'], notifications: ['settings.notifications'],
@@ -211,6 +218,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
calendar: ['calendar.settings', 'calendar.management'], calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'], contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'], files: ['settings.files'],
protocol_handlers: ['protocol_handlers'],
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
about_data: ['settings.advanced'], about_data: ['settings.advanced'],
themes: [], themes: [],
@@ -221,7 +229,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
// Extra English keywords per tab so common search terms hit even when the // Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word. // translation doesn't contain the literal word.
const tabKeywords: Record<Tab, string> = { const tabKeywords: Record<Tab, string> = {
account: 'profile email password user signin signout', account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account',
language: 'locale region timezone date time format', language: 'locale region timezone date time format',
notifications: 'sound alert push badge', notifications: 'sound alert push badge',
appearance: 'theme dark light font size accent color animation density', appearance: 'theme dark light font size accent color animation density',
@@ -240,6 +248,7 @@ const tabKeywords: Record<Tab, string> = {
calendar: 'event schedule appointment meeting timezone', calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact', contacts: 'address book contact',
files: 'attachments cloud drive storage upload', files: 'attachments cloud drive storage upload',
protocol_handlers: 'mailto webcal links default app protocol handler',
sidebar_apps: 'apps webview iframe', sidebar_apps: 'apps webview iframe',
about_data: 'export import storage quota privacy backup', about_data: 'export import storage quota privacy backup',
themes: 'custom theme css skin appearance', themes: 'custom theme css skin appearance',
@@ -339,6 +348,7 @@ export default function SettingsPage() {
const tSidebar = useTranslations('sidebar'); const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const isEmbedded = useIsEmbedded();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig(); const { stalwartFeaturesEnabled } = useConfig();
@@ -353,6 +363,7 @@ export default function SettingsPage() {
const installedPlugins = usePluginStore((s) => s.plugins); const installedPlugins = usePluginStore((s) => s.plugins);
const installedThemes = useThemeStore((s) => s.installedThemes); const installedThemes = useThemeStore((s) => s.installedThemes);
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// Build a per-tab haystack for fulltext search and a list of sub-results // Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries // (individual settings) per tab. Sub-results come from translation entries
@@ -560,6 +571,7 @@ export default function SettingsPage() {
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
// Appearance // Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
@@ -582,8 +594,8 @@ export default function SettingsPage() {
// Apps // Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' }, ...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Advanced // Advanced
@@ -666,6 +678,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>} {effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />} {effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />} {effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />} {effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />} {effectiveActiveTab === 'themes' && <ThemesSettings />}
@@ -678,7 +691,8 @@ export default function SettingsPage() {
if (!isDesktop) { if (!isDesktop) {
if (mobileShowContent) { if (mobileShowContent) {
return ( return (
<div className="flex flex-col h-dvh bg-background"> <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -695,20 +709,23 @@ export default function SettingsPage() {
{renderTabContent()} {renderTabContent()}
</div> </div>
<NavigationRail {!isEmbedded && (
orientation="horizontal" <NavigationRail
onManageApps={handleManageApps} orientation="horizontal"
onInlineApp={handleInlineApp} onManageApps={handleManageApps}
onCloseInlineApp={closeInlineApp} onInlineApp={handleInlineApp}
activeAppId={inlineApp?.id ?? null} onCloseInlineApp={closeInlineApp}
/> activeAppId={inlineApp?.id ?? null}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col h-dvh bg-background"> <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -804,13 +821,15 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<NavigationRail {!isEmbedded && (
orientation="horizontal" <NavigationRail
onManageApps={handleManageApps} orientation="horizontal"
onInlineApp={handleInlineApp} onManageApps={handleManageApps}
onCloseInlineApp={closeInlineApp} onInlineApp={handleInlineApp}
activeAppId={inlineApp?.id ?? null} onCloseInlineApp={closeInlineApp}
/> activeAppId={inlineApp?.id ?? null}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div> </div>
); );
@@ -818,19 +837,23 @@ export default function SettingsPage() {
// Desktop layout // Desktop layout
return ( return (
<div className="flex h-dvh bg-background"> <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <AppTopBannerSlot />
<NavigationRail <div className="flex flex-1 min-h-0">
collapsed {!isEmbedded && (
quota={quota} <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
isPushConnected={isPushConnected} <NavigationRail
onLogout={logout} collapsed
onManageApps={handleManageApps} quota={quota}
onInlineApp={handleInlineApp} isPushConnected={isPushConnected}
onCloseInlineApp={closeInlineApp} onLogout={logout}
activeAppId={inlineApp?.id ?? null} onManageApps={handleManageApps}
/> onInlineApp={handleInlineApp}
</div> onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
@@ -844,17 +867,19 @@ export default function SettingsPage() {
)} )}
style={{ width: `${settingsSidebarWidth}px` }} style={{ width: `${settingsSidebarWidth}px` }}
> >
<div className="p-4 border-b border-border"> {!proInterface && (
<Button <div className="p-4 border-b border-border">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => router.push('/')} size="sm"
className="w-full justify-start" onClick={() => router.push('/')}
> className="w-full justify-start"
<ArrowLeft className="w-4 h-4 mr-2" /> >
{t('back_to_mail')} <ArrowLeft className="w-4 h-4 mr-2" />
</Button> {t('back_to_mail')}
</div> </Button>
</div>
)}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs"> <div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-3 pt-1 pb-1"> <div className="px-3 pt-1 pb-1">
@@ -950,6 +975,7 @@ export default function SettingsPage() {
</> </>
)} )}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
</div> </div>
); );
} }
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; // Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead - the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default'; source: 'admin' | 'env' | 'default';
hasValue?: boolean;
} }
export function AuthTab() { export function AuthTab() {
@@ -267,8 +271,10 @@ export function AuthTab() {
<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} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} /> <Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" /> <Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" /> <Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
</Section> </Section>
<Section title="Single Sign-On"> <Section title="Single Sign-On">
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value?: unknown;
source: 'admin' | 'env' | 'default'; source: 'admin' | 'env' | 'default';
hasValue?: boolean;
} }
const IMAGE_FIELDS = [ const IMAGE_FIELDS = [
@@ -24,6 +25,20 @@ const TEXT_FIELDS = [
{ key: 'loginWebsiteUrl', label: 'Company Website URL' }, { key: 'loginWebsiteUrl', label: 'Company Website URL' },
]; ];
const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
];
const PWA_TEXT_FIELDS = [
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
];
const PWA_COLOR_FIELDS = [
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
];
export function BrandingTab() { export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({}); const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({}); const [edits, setEdits] = useState<Record<string, unknown>>({});
@@ -261,6 +276,142 @@ export function BrandingTab() {
</div> </div>
</div> </div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
</div>
<div className="divide-y divide-border">
{PWA_IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<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">
<label className="text-sm text-foreground">{field.label}</label>
{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">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
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
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={currentValue(field.key)}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
))}
{PWA_TEXT_FIELDS.map(field => (
<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">
<label className="text-sm text-foreground">{field.label}</label>
{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>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.placeholder}
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' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
{PWA_COLOR_FIELDS.map(field => {
const value = currentValue(field.key) || field.defaultValue;
return (
<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">
<label className="text-sm text-foreground">{field.label}</label>
{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>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
onChange={(e) => handleChange(field.key, e.target.value)}
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
title="Pick a color"
/>
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.defaultValue}
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Company Information</h2> <h2 className="text-sm font-medium text-foreground">Company Information</h2>
@@ -26,7 +26,7 @@ export function DashboardTab() {
const [status, setStatus] = useState<AdminStatus | null>(null); const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]); const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null); const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null); const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0); const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0); const [themeCount, setThemeCount] = useState(0);
@@ -96,7 +96,9 @@ export function DashboardTab() {
const sources = await adminConfigRes.json(); const sources = await adminConfigRes.json();
setConfigSources(sources); setConfigSources(sources);
const sessionSecret = sources?.sessionSecret; const sessionSecret = sources?.sessionSecret;
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { // Server redacts the raw value for sensitive keys; rely on hasValue,
// which is false when unset or matching a known placeholder default.
if (!sessionSecret?.hasValue) {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.'); w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
} }
const adminPassword = sources?.adminPassword; const adminPassword = sources?.adminPassword;
@@ -2,8 +2,11 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface Extension { interface Extension {
slug: string; slug: string;
@@ -18,6 +21,7 @@ interface Extension {
minAppVersion: string | null; minAppVersion: string | null;
latestVersion: string | null; latestVersion: string | null;
installed: boolean; installed: boolean;
installedVersion: string | null;
iconUrl: string | null; iconUrl: string | null;
bannerUrl: string | null; bannerUrl: string | null;
author: { author: {
@@ -94,6 +98,15 @@ export function MarketplaceTab() {
}, [searchInput]); }, [searchInput]);
async function handleInstall(ext: Extension) { async function handleInstall(ext: Extension) {
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
setMessage({
type: 'error',
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
});
return;
}
const isUpdate = ext.installed;
const targetVersion = ext.latestVersion || '1.0.0';
setInstalling(ext.slug); setInstalling(ext.slug);
setMessage(null); setMessage(null);
@@ -103,7 +116,7 @@ export function MarketplaceTab() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: ext.slug, slug: ext.slug,
version: ext.latestVersion || '1.0.0', version: targetVersion,
type: ext.type, type: ext.type,
}), }),
}); });
@@ -112,13 +125,22 @@ export function MarketplaceTab() {
if (res.ok) { if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); setMessage({
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); type: 'success',
text: isUpdate
? `"${ext.name}" updated to v${targetVersion}${warnings}`
: `"${ext.name}" installed successfully${warnings}`,
});
setExtensions(prev => prev.map(e =>
e.slug === ext.slug
? { ...e, installed: true, installedVersion: targetVersion }
: e,
));
} else { } else {
setMessage({ type: 'error', text: data.error || 'Installation failed' }); setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(null); setInstalling(null);
} }
@@ -258,6 +280,13 @@ function ExtensionCard({
}) { }) {
const isPlugin = extension.type === 'plugin'; const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
const updateAvailable = extension.installed
&& !!extension.installedVersion
&& !!extension.latestVersion
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
&& !versionMismatch;
return ( return (
<div className="group relative 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">
@@ -346,12 +375,37 @@ function ExtensionCard({
</div> </div>
</Link> </Link>
<div className="px-4 pb-4 -mt-1"> <div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed ? ( {extension.installed && updateAvailable ? (
<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"> <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ArrowUpCircle className="w-3 h-3" />
)}
Update to v{extension.latestVersion}
</button>
) : 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"
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
Installed Installed
</span> </span>
) : versionMismatch ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
>
<AlertTriangle className="w-3 h-3" />
Requires v{extension.minAppVersion}+
</span>
) : ( ) : (
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
interface ConfigField { interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
@@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p> <p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div> </div>
)} )}
<PluginAdminSection pluginId={pluginId} />
</div>
);
}
/**
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
* and the host gives it a dedicated iframe inside the admin panel.
*/
function PluginAdminSection({ pluginId }: { pluginId: string }) {
const offers = usePluginSlotOffers('admin-plugin-page');
const offer = offers.find((o) => o.pluginId === pluginId);
if (!offer) return null;
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Plugin admin panel
</div>
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
</div> </div>
); );
} }
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value?: unknown;
source: 'admin' | 'env' | 'default'; source: 'admin' | 'env' | 'default';
hasValue?: boolean;
} }
export function SettingsTab() { export function SettingsTab() {
@@ -27,11 +27,12 @@ import {
} 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';
import { usePolicyStore } from '@/stores/policy-store';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
// Single-page tab navigation: clicks update a Zustand store. The URL stays // Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch - // at /admin so React doesn't fire a route transition on every tab switch -
@@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
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)
@@ -177,6 +179,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>; return <>{children}</>;
} }
// /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix();
const navContent = ( const navContent = (
<> <>
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
@@ -274,39 +282,41 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="w-7 h-7 mb-2" /> <div className="w-7 h-7 mb-2" />
)} )}
<a <a
href="/" href={`${prefix}/`}
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"
title="Mail" title="Mail"
> >
<Mail className="w-[18px] h-[18px]" /> <Mail className="w-[18px] h-[18px]" />
</a> </a>
<a <a
href="/calendar" href={`${prefix}/calendar`}
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"
title="Calendar" title="Calendar"
> >
<Calendar className="w-[18px] h-[18px]" /> <Calendar className="w-[18px] h-[18px]" />
</a> </a>
<a <a
href="/contacts" href={`${prefix}/contacts`}
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"
title="Contacts" title="Contacts"
> >
<BookUser className="w-[18px] h-[18px]" /> <BookUser className="w-[18px] h-[18px]" />
</a> </a>
<a {filesEnabled && (
href="/files" <a
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted" href={`${prefix}/files`}
title="Files" className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
> title="Files"
<HardDrive className="w-[18px] h-[18px]" /> >
</a> <HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2"> <div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin"> <div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" /> <Shield className="w-[18px] h-[18px]" />
</div> </div>
<a <a
href="/settings" href={`${prefix}/settings`}
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"
title="Settings" title="Settings"
> >
@@ -411,7 +421,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
aria-label="Main navigation" aria-label="Main navigation"
> >
<a <a
href="/" href={`${prefix}/`}
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" 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" title="Mail"
> >
@@ -419,7 +429,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a> </a>
<a <a
href="/calendar" href={`${prefix}/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" 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" title="Calendar"
> >
@@ -427,21 +437,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a> </a>
<a <a
href="/contacts" href={`${prefix}/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" 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" title="Contacts"
> >
<BookUser className="w-5 h-5" /> <BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a> </a>
<a {filesEnabled && (
href="/files" <a
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" href={`${prefix}/files`}
title="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> <HardDrive className="w-5 h-5" />
</a> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div <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" 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" title="Admin"
@@ -454,7 +466,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div> </div>
<a <a
href="/settings" href={`${prefix}/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" 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" title="Settings"
> >
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import {
ArrowLeft, ArrowLeft,
ArrowUpCircle,
Download, Download,
Loader2, Loader2,
Puzzle, Puzzle,
@@ -21,6 +22,9 @@ import {
ChevronUp, ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface PreviewData { interface PreviewData {
extension: { extension: {
@@ -70,6 +74,7 @@ interface PreviewData {
error: string | null; error: string | null;
}; };
installed: boolean; installed: boolean;
installedVersion: string | null;
} }
const RISKY_PERMISSIONS = new Set([ const RISKY_PERMISSIONS = new Set([
@@ -115,6 +120,8 @@ export default function MarketplacePreviewPage() {
async function handleInstall() { async function handleInstall() {
if (!data) return; if (!data) return;
const isUpdate = data.installed;
const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true); setInstalling(true);
setMessage(null); setMessage(null);
try { try {
@@ -123,20 +130,25 @@ export default function MarketplacePreviewPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: data.extension.slug, slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0', version: targetVersion,
type: data.extension.type, type: data.extension.type,
}), }),
}); });
const body = await res.json(); const body = await res.json();
if (res.ok) { if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` }); setMessage({
setData(prev => prev ? { ...prev, installed: true } : prev); type: 'success',
text: isUpdate
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
: `"${data.extension.name}" installed${warnings}`,
});
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
} else { } else {
setMessage({ type: 'error', text: body.error || 'Installation failed' }); setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(false); setInstalling(false);
} }
@@ -200,6 +212,12 @@ export default function MarketplacePreviewPage() {
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || []; const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; 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; const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
const updateAvailable = data.installed
&& !!data.installedVersion
&& !!ext.latestVersion
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
&& !versionMismatch;
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
@@ -244,11 +262,22 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <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> <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" />} {ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && ( {data.installed && !updateAvailable && (
<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"> <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"
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> Installed <Check className="w-3 h-3" /> Installed
</span> </span>
)} )}
{data.installed && updateAvailable && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
>
<ArrowUpCircle className="w-3 h-3" /> Update available
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap"> <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 ${ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
@@ -275,6 +304,17 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? ( {data.installed ? (
<> <>
{updateAvailable && (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
Update to v{ext.latestVersion}
</button>
)}
<Link <Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'} 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" 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"
@@ -294,8 +334,11 @@ export default function MarketplacePreviewPage() {
) : ( ) : (
<button <button
onClick={handleInstall} onClick={handleInstall}
disabled={installing || !!bundle.error} disabled={installing || !!bundle.error || versionMismatch}
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" title={versionMismatch
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
: undefined}
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 disabled:cursor-not-allowed transition-colors"
> >
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />} {installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install Install
@@ -310,6 +353,18 @@ export default function MarketplacePreviewPage() {
</div> </div>
)} )}
{versionMismatch && (
<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">Update Bulwark to install this extension</p>
<p className="text-xs mt-0.5 opacity-90">
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
</p>
</div>
</div>
)}
{bundle.error && ( {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"> <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" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
+12 -4
View File
@@ -1,10 +1,11 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css"; import { configManager } from "@/lib/admin/config-manager";
import "../globals.css";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -16,8 +17,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const faviconUrl = process.env.FAVICON_URL; await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
return { return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
@@ -30,7 +38,7 @@ export async function generateMetadata(): Promise<Metadata> {
formatDetection: { formatDetection: {
telephone: false, telephone: false,
}, },
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}), icons: { icon: faviconUrl },
}; };
} }
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
export default async function WebcalProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
}
+126 -10
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
type State = 'bootstrap' | 'configured' | 'env-managed'; type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -101,6 +101,14 @@ export default function SetupWizardPage() {
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG); const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
const [stepIndex, setStepIndex] = useState(0); const [stepIndex, setStepIndex] = useState(0);
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
// Resolved in a post-mount effect, not at render, so the server-rendered
// HTML (where window is absent) matches the client's first paint and
// doesn't trip a hydration mismatch.
const [insecureContext, setInsecureContext] = useState(false);
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
useEffect(() => {
setInsecureContext(detectInsecureContext());
}, []);
// ─── Initial status load ──────────────────────────────────────────────── // ─── Initial status load ────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -173,6 +181,10 @@ export default function SetupWizardPage() {
} }
// ─── Render shell ─────────────────────────────────────────────────────── // ─── Render shell ───────────────────────────────────────────────────────
if (insecureContext && !insecureAcknowledged) {
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
}
if (bootstrapping) { if (bootstrapping) {
return <CenteredCard><p className="text-muted-foreground">Loading</p></CenteredCard>; return <CenteredCard><p className="text-muted-foreground">Loading</p></CenteredCard>;
} }
@@ -229,7 +241,7 @@ export default function SetupWizardPage() {
} catch (e) { } catch (e) {
const msg = humanError(e); const msg = humanError(e);
setError(msg); setError(msg);
// Session expired mid-flow kick the user back to the // Session expired mid-flow - kick the user back to the
// welcome step so they can re-enter the token without // welcome step so they can re-enter the token without
// having to refresh. // having to refresh.
if (/wizard session required/i.test(msg)) { if (/wizard session required/i.test(msg)) {
@@ -241,7 +253,7 @@ export default function SetupWizardPage() {
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))} onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
onFinish={() => { onFinish={() => {
setCompleted(true); setCompleted(true);
// Hard navigation after a beat gives the user a moment // Hard navigation after a beat - gives the user a moment
// to see the success screen and works around any router // to see the success screen and works around any router
// edge cases that swallow client-side replaces after the // edge cases that swallow client-side replaces after the
// setupComplete flag flips. // setupComplete flag flips.
@@ -347,6 +359,44 @@ function CompletedScreen() {
); );
} }
function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
const httpsUrl =
typeof window !== 'undefined'
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
: '';
return (
<CenteredCard>
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
<ShieldAlert className="h-6 w-6" />
</div>
<h1 className="text-xl font-semibold">You&apos;re running setup over plain HTTP</h1>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
The setup token and admin password you enter here will travel in cleartext.
Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.
</p>
</div>
<div className="mt-6 space-y-2">
{httpsUrl && (
<a
href={httpsUrl}
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
>
Try HTTPS
</a>
)}
<button
type="button"
onClick={onContinue}
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
>
Continue over HTTP
</button>
</div>
</CenteredCard>
);
}
function AlreadyConfiguredScreen() { function AlreadyConfiguredScreen() {
return ( return (
<CenteredCard> <CenteredCard>
@@ -536,7 +586,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
const data = await res.json(); const data = await res.json();
let entry: { status: ProbeStatus; message: string; url: string }; let entry: { status: ProbeStatus; message: string; url: string };
if (data.status === 'jmap_detected') { if (data.status === 'jmap_detected') {
entry = { status: 'jmap_detected', message: 'Connected this looks like a JMAP server.', url: config.jmapServerUrl }; entry = { status: 'jmap_detected', message: 'Connected - this looks like a JMAP server.', url: config.jmapServerUrl };
} else if (data.status === 'reachable_no_jmap') { } else if (data.status === 'reachable_no_jmap') {
entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl }; entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl };
} else if (data.status === 'invalid_url') { } else if (data.status === 'invalid_url') {
@@ -618,7 +668,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
} }
if (!result) return; if (!result) return;
// Hard-fail on these no "are you sure" since they can't be right. // Hard-fail on these - no "are you sure" since they can't be right.
if (result.status === 'invalid_url' || result.status === 'unreachable') { if (result.status === 'invalid_url' || result.status === 'unreachable') {
return; return;
} }
@@ -685,7 +735,22 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
This URL uses plain HTTP. This URL uses plain HTTP.
</p> </p>
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed"> <p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production terminate TLS on the mail server or a reverse proxy in front of it. Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production - terminate TLS on the mail server or a reverse proxy in front of it.
</p>
</div>
</div>
)}
{isPrivateOrLocalHostUrl(config.jmapServerUrl) && (
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
<AlertTriangle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center">
<p className="text-sm font-medium text-foreground leading-relaxed">
This URL only resolves locally.
</p>
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
Mail is fetched directly from the user&apos;s browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. <code className="font-mono text-xs">https://mail.example.com</code>) in production.
</p> </p>
</div> </div>
</div> </div>
@@ -720,7 +785,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
onChange={(e) => setConfirmedNonJmap(e.target.checked)} onChange={(e) => setConfirmedNonJmap(e.target.checked)}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="text-sm text-foreground">I&apos;m sure this is the right URL continue anyway.</span> <span className="text-sm text-foreground">I&apos;m sure this is the right URL - continue anyway.</span>
</label> </label>
</div> </div>
) : ( ) : (
@@ -1105,7 +1170,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
<form onSubmit={handle} className="space-y-4"> <form onSubmit={handle} className="space-y-4">
<StepHeader <StepHeader
title="Branding" title="Branding"
subtitle="All fields optional. Upload a file or paste a URL defaults are used for anything you skip." subtitle="All fields optional. Upload a file or paste a URL - defaults are used for anything you skip."
/> />
<Field label="Company / organization name"> <Field label="Company / organization name">
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} /> <Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
@@ -1174,7 +1239,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
* One branding asset slot: shows a thumbnail preview if a value is set, * One branding asset slot: shows a thumbnail preview if a value is set,
* a file picker (uploads to /api/setup/branding), and a URL field for * a file picker (uploads to /api/setup/branding), and a URL field for
* operators who'd rather paste a link. Upload and URL are mutually * operators who'd rather paste a link. Upload and URL are mutually
* compatible the URL field always reflects the persisted value. * compatible - the URL field always reflects the persisted value.
*/ */
function BrandingAsset({ function BrandingAsset({
label, label,
@@ -1522,7 +1587,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
<div className="flex justify-between items-baseline gap-3 text-sm"> <div className="flex justify-between items-baseline gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span> <span className="text-muted-foreground shrink-0">{label}</span>
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}> <span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
{value || <span className="text-muted-foreground italic"></span>} {value || <span className="text-muted-foreground italic">-</span>}
</span> </span>
</div> </div>
); );
@@ -1729,6 +1794,57 @@ function isInsecureHttpUrl(url: string): boolean {
return /^http:\/\//i.test(url.trim()); return /^http:\/\//i.test(url.trim());
} }
/**
* The JMAP URL is called directly from the user's browser. A URL that only
* resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS)
* works during setup but breaks for any real user. Surface a soft warning
* so the operator catches this before going live.
*/
function isPrivateOrLocalHostUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return false;
let host: string;
try {
host = new URL(trimmed).hostname.toLowerCase();
} catch {
return false;
}
// Strip IPv6 brackets, if any.
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
if (host === 'localhost' || host.endsWith('.localhost')) return true;
if (host.endsWith('.local')) return true;
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;
// IPv4 literal: only flag the well-known private/loopback/link-local ranges.
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
if (a === 10) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
}
return false;
}
function detectInsecureContext(): boolean {
if (typeof window === 'undefined') return false;
if (window.location.protocol !== 'http:') return false;
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
// Secure cookies even without TLS, so the wizard still works there. In dev
// we still want to render the warning so we can preview it without spinning
// up a non-loopback host.
const host = window.location.hostname;
const isLoopback =
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
if (isLoopback && process.env.NODE_ENV !== 'development') {
return false;
}
return true;
}
function humanError(e: unknown): string { function humanError(e: unknown): string {
if (e instanceof Error) return e.message; if (e instanceof Error) return e.message;
if (typeof e === 'string') return e; if (typeof e === 'string') return e;
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import '../globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Plugin sandbox',
robots: { index: false, follow: false },
};
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
{children}
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
// Next's injected hydration/chunk scripts. With force-static, those scripts
// render without a nonce and the strict sandbox CSP blocks them.
export const dynamic = 'force-dynamic';
export default function PluginSandboxPage() {
return <SandboxRuntime />;
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10)); const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
Binary file not shown.
+3 -2
View File
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
/** Slots that correspond to branding config keys */ /** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([ const VALID_SLOTS = new Set([
'faviconUrl', 'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl', 'appLogoLightUrl',
'appLogoDarkUrl', 'appLogoDarkUrl',
'loginLogoLightUrl', 'loginLogoLightUrl',
@@ -44,7 +45,7 @@ function sanitizeFilename(name: string): string {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -114,7 +115,7 @@ export async function POST(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+1 -1
View File
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+29 -6
View File
@@ -2,22 +2,45 @@ import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit'; import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP } from '@/lib/admin/types'; import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers'; import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
/** /**
* GET /api/admin/config - Get full config with sources (admin-protected) * GET /api/admin/config - Get full config with sources (admin-protected)
*
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
* `value` omitted and a `hasValue` boolean instead. An admin session is
* enough to read every other config knob; the secrets themselves stay on
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const config = configManager.getAllWithSources(); const config = configManager.getAllWithSources();
return NextResponse.json(config, { const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
for (const [key, entry] of Object.entries(config)) {
if (SENSITIVE_CONFIG_KEYS.has(key)) {
const v = entry.value;
const hasValue =
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
safe[key] = { source: entry.source, hasValue };
} else {
safe[key] = entry;
}
}
return NextResponse.json(safe, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch (error) { } catch (error) {
@@ -31,7 +54,7 @@ export async function GET() {
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -86,7 +109,7 @@ export async function PATCH(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+18 -10
View File
@@ -7,8 +7,12 @@ import {
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
const MAX_PREVIEW_SOURCE_LEN = 100_000; const MAX_PREVIEW_SOURCE_LEN = 100_000;
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
* Lets admins audit what they're about to install before pressing the button. * Lets admins audit what they're about to install before pressing the button.
*/ */
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }, { params }: { params: Promise<{ slug: string }> },
) { ) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { slug } = await params; const { slug } = await params;
const directoryUrl = await getDirectoryUrl();
// 1. Extension metadata + screenshots + theme previews from the directory // 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL); const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
const detailRes = await fetch(detailUrl.toString(), { const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
@@ -63,7 +68,7 @@ export async function GET(
try { try {
const bundleUrl = new URL( const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`, `/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL, directoryUrl,
); );
const bundleRes = await fetch(bundleUrl.toString(), { const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
@@ -144,14 +149,16 @@ export async function GET(
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installed = type === 'theme' const installedEntry = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug) ? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.id === slug); : pluginRegistry.plugins.find((p) => p.id === slug);
const installed = installedEntry !== undefined;
const installedVersion = installedEntry?.version ?? null;
// 4. Build screenshot URLs (proxy through the directory's public files endpoint). // 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots) const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({ ? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(), url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
altText: s.altText ?? null, altText: s.altText ?? null,
})) }))
: []; : [];
@@ -170,7 +177,7 @@ export async function GET(
const fileUrl = (path: unknown): string | null => const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() ? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null; : null;
return NextResponse.json( return NextResponse.json(
@@ -206,6 +213,7 @@ export async function GET(
error: bundleError, error: bundleError,
}, },
installed, installed,
installedVersion,
}, },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
); );
+121 -28
View File
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import { import {
savePlugin, savePlugin,
saveTheme, saveTheme,
getPlugin,
getTheme,
getPluginRegistry, getPluginRegistry,
getThemeRegistry, getThemeRegistry,
type ServerPlugin, type ServerPlugin,
@@ -13,13 +15,18 @@ import {
import { import {
sanitizeFrameOrigins, sanitizeFrameOrigins,
sanitizeHttpOrigins, sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache, invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins'; } 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';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
/** /**
* GET /api/admin/marketplace - Search/browse the extension directory * GET /api/admin/marketplace - Search/browse the extension directory
@@ -27,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const directoryUrl = await getDirectoryUrl();
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', DIRECTORY_URL); const url = new URL('/api/v1/extensions', directoryUrl);
// Forward all search params // Forward all search params
for (const [key, value] of searchParams.entries()) { for (const [key, value] of searchParams.entries()) {
@@ -58,23 +66,32 @@ export async function GET(request: NextRequest) {
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); const installedPluginVersions = new Map(
const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); pluginRegistry.plugins.map(p => [p.id, p.version] as const),
);
const installedThemeVersions = new Map(
themeRegistry.themes.map(t => [t.id, t.version] as const),
);
const fileUrl = (path: unknown): string | null => const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() ? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null; : null;
if (data.data) { if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({ data.data = data.data.map((ext: Record<string, unknown>) => {
...ext, const slug = ext.slug as string;
iconUrl: fileUrl(ext.iconPath), const installedVersion = ext.type === 'theme'
bannerUrl: fileUrl(ext.bannerPath), ? installedThemeVersions.get(slug) ?? null
installed: ext.type === 'theme' : installedPluginVersions.get(slug) ?? null;
? installedThemes.has(ext.slug as string) return {
: installedPlugins.has(ext.slug as string), ...ext,
})); iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: installedVersion !== null,
installedVersion,
};
});
} }
return NextResponse.json(data, { return NextResponse.json(data, {
@@ -92,7 +109,7 @@ export async function GET(request: NextRequest) {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -107,7 +124,8 @@ export async function POST(request: NextRequest) {
} }
// Download the bundle from the directory // Download the bundle from the directory
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL); const directoryUrl = await getDirectoryUrl();
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
const bundleRes = await fetch(bundleUrl.toString(), { const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
}); });
@@ -163,6 +181,18 @@ export async function POST(request: NextRequest) {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Resolve and strictly validate the id used as a filename. Marketplace
// bundles are authored by a third-party publisher; without this an id
// like "../../foo" causes savePlugin/saveTheme to write outside the
// plugins/themes dir via path.join.
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
return NextResponse.json(
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
{ status: 400 },
);
}
if (type === 'theme') { if (type === 'theme') {
// Read theme.css // Read theme.css
const cssFile = zip.file(root + 'theme.css'); const cssFile = zip.file(root + 'theme.css');
@@ -181,22 +211,43 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings); warnings.push(...sanitized.warnings);
} }
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = { const theme: ServerTheme = {
id: (manifest.id as string) || slug, id: resolvedId,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
version: (manifest.version as string) || version, // Prefer the directory-published version (what we requested) over
// manifest.version. Publishers sometimes forget to bump the version
// inside the bundle's manifest.json; trusting it would make the
// update never appear to "stick" — the registry would keep showing
// the older version even after a successful update.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'], variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true, enabled: existingTheme?.enabled ?? true,
installedAt: now, ...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now, updatedAt: now,
}; };
await saveTheme(theme, css); await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip); await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
id: theme.id,
name: theme.name,
version: theme.version,
slug,
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, theme, warnings }); return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
} else { } else {
// Plugin installation // Plugin installation
// Read entrypoint JS // Read entrypoint JS
@@ -266,31 +317,73 @@ export async function POST(request: NextRequest) {
); );
} }
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
? (manifest.apiPostPaths as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
)
: [];
if (droppedApiPostPaths.length > 0) {
warnings.push(
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
);
}
const existingPlugin = await getPlugin(resolvedId);
const isUpdate = existingPlugin !== null;
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
id: (manifest.id as string) || slug, id: resolvedId,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
version: (manifest.version as string) || version, // See theme branch: trust the directory-published version, not
// manifest.version, so updates actually stick in the registry.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook', type: (manifest.type as string) || 'hook',
permissions, permissions,
entrypoint, entrypoint,
enabled: true, enabled: existingPlugin?.enabled ?? true,
installedAt: now, ...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now, updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(declaredFrameOrigins.length > 0 ...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins } ? { frameOrigins: declaredFrameOrigins }
: {}), : {}),
...(declaredHttpOrigins.length > 0 ...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins } ? { httpOrigins: declaredHttpOrigins }
: {}), : {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
invalidateFrameOriginsCache(); invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); await auditLog(
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
{
id: plugin.id,
name: plugin.name,
version: plugin.version,
slug,
frameOrigins: declaredFrameOrigins,
httpOrigins: declaredHttpOrigins,
apiPostPaths: declaredApiPostPaths,
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, plugin, warnings }); return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
} }
} catch (error) { } catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
+1 -1
View File
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+85
View File
@@ -0,0 +1,85 @@
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 { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
*
* GET /api/admin/plugin-approvals → list all entries
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
*/
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const entries = await listApprovals();
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
if (b.decision !== 'approved' && b.decision !== 'denied') {
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
}
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
} catch (err) {
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
await revokeApproval(pluginId, bundleHash);
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
return NextResponse.json({ ok: true });
} catch (err) {
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+17 -8
View File
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
import { signBytes } from '@/lib/admin/plugin-signing';
async function safeSign(code: string): Promise<string | null> {
try { return await signBytes(code); } catch { return null; }
}
/** /**
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
@@ -25,14 +30,15 @@ export async function GET(
const devEntry = await getDevPlugin(id); const devEntry = await getDevPlugin(id);
if (devEntry) { if (devEntry) {
const code = await readDevBundle(devEntry); const code = await readDevBundle(devEntry);
return new NextResponse(code, { const signature = await safeSign(code);
headers: { const headers: Record<string, string> = {
'Content-Type': 'application/javascript; charset=utf-8', 'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'ETag': `"${devEntry.plugin.bundleHash}"`, 'ETag': `"${devEntry.plugin.bundleHash}"`,
'Content-Length': String(Buffer.byteLength(code, 'utf-8')), 'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
}, };
}); if (signature) headers['X-Bundle-Signature'] = signature;
return new NextResponse(code, { headers });
} }
const plugin = await getPlugin(id); const plugin = await getPlugin(id);
@@ -59,6 +65,9 @@ export async function GET(
}; };
if (etag) headers['ETag'] = etag; if (etag) headers['ETag'] = etag;
const signature = await safeSign(code);
if (signature) headers['X-Bundle-Signature'] = signature;
if (etag && request.headers.get('if-none-match') === etag) { if (etag && request.headers.get('if-none-match') === etag) {
return new NextResponse(null, { status: 304, headers }); return new NextResponse(null, { status: 304, headers });
} }
+21 -9
View File
@@ -34,7 +34,7 @@ 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 adminAuth = await requireAdminAuth(request);
const isAdmin = !('error' in adminAuth); const isAdmin = !('error' in adminAuth);
if (!isAdmin) { if (!isAdmin) {
@@ -51,13 +51,18 @@ export async function GET(
const config = await getPluginConfig(id); const config = await getPluginConfig(id);
let response: Record<string, unknown> = config; let response: Record<string, unknown>;
if (!isAdmin && plugin.configSchema) { if (isAdmin) {
response = config;
} else {
response = {}; response = {};
for (const [key, value] of Object.entries(config)) { const schema = plugin.configSchema;
const field = plugin.configSchema[key]; if (schema) {
if (field?.type === 'secret') continue; for (const [key, value] of Object.entries(config)) {
response[key] = value; const field = schema[key];
if (!field || field.type === 'secret') continue;
response[key] = value;
}
} }
} }
@@ -80,7 +85,7 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { id } = await params; const { id } = await params;
@@ -110,6 +115,13 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 }); return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
} }
if (plugin.configSchema && !plugin.configSchema[body.key]) {
return NextResponse.json(
{ error: 'Key is not declared in the plugin configSchema' },
{ status: 400 },
);
}
await setPluginConfig(id, body.key, body.value); await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch { } catch {
@@ -127,7 +139,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { id } = await params; const { id } = await params;
+22 -8
View File
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { import {
sanitizeFrameOrigins, sanitizeFrameOrigins,
sanitizeHttpOrigins, sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache, invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins'; } from '@/lib/admin/csp-frame-origins';
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
/** /**
* GET /api/admin/plugins - List all admin-managed plugins * GET /api/admin/plugins - List all admin-managed plugins
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const [registry, devEntries] = await Promise.all([ const [registry, devEntries] = await Promise.all([
@@ -63,7 +64,7 @@ export async function GET() {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const now = new Date().toISOString(); const now = new Date().toISOString();
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
...(declaredHttpOrigins.length > 0 ...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins } ? { httpOrigins: declaredHttpOrigins }
: {}), : {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
invalidateFrameOriginsCache(); invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
return NextResponse.json({ plugin }); return NextResponse.json({ plugin });
} catch (error) { } catch (error) {
@@ -217,7 +222,7 @@ export async function POST(request: NextRequest) {
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -235,9 +240,18 @@ export async function PATCH(request: NextRequest) {
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updatePluginMeta(id, updates); let updated = await updatePluginMeta(id, updates);
if (!updated) { if (!updated) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); // Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
// client. Skip the registry write and return the live dev plugin so the
// policy save path can proceed.
const devEntries = await listDevPlugins();
const devEntry = devEntries.find(e => e.plugin.id === id);
if (!devEntry) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
updated = { ...devEntry.plugin, ...updates };
} }
// Enable/disable changes the set of plugins contributing frame origins. // Enable/disable changes the set of plugins contributing frame origins.
@@ -259,7 +273,7 @@ export async function PATCH(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+1 -1
View File
@@ -26,7 +26,7 @@ export async function GET() {
*/ */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+3 -3
View File
@@ -19,9 +19,9 @@ import {
* Returns current consent + endpoint + next/last send + a live preview * Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain. * of exactly what the next heartbeat would contain.
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent(); const { consent, source, state } = await effectiveConsent();
@@ -61,7 +61,7 @@ export async function GET() {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+5 -5
View File
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/** /**
* GET /api/admin/themes - List all admin-managed themes * GET /api/admin/themes - List all admin-managed themes
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const registry = await getThemeRegistry(); const registry = await getThemeRegistry();
@@ -36,7 +36,7 @@ export async function GET() {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+3 -3
View File
@@ -13,9 +13,9 @@ import {
* GET /api/admin/version * GET /api/admin/version
* Returns the cached update status, last check times, and effective config. * Returns the cached update status, last check times, and effective config.
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const state = await loadState(); const state = await loadState();
@@ -47,7 +47,7 @@ export async function GET() {
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(req);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null; const body = (await req.json().catch(() => null)) as { action?: string } | null;
+141
View File
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
export const runtime = 'nodejs';
const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies - the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
*/
function impersonationCookieOptions() {
const { maxAge: _maxAge, ...rest } = getCookieOptions();
return rest;
}
/**
* GET /api/auth/impersonate?token=<jwt>
*
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
*/
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured - behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
let claims;
try {
claims = verifyImpersonationJwt(token, config.jwtSecret, {
expectedIssuer: config.expectedIssuer,
});
} catch (err) {
if (err instanceof ImpersonationJwtError) {
logger.warn('Impersonation JWT rejected', { code: err.code });
return NextResponse.json({ error: err.message }, { status: err.status });
}
logger.error('Impersonation JWT error', {
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
const serverUrl = await resolveImpersonationServerUrl();
if (!serverUrl) {
logger.error('Impersonation requested but jmapServerUrl is not configured');
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
let normalizedServerUrl: string;
try {
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
} catch {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
});
// Structured audit log - operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
mailbox: claims.mailbox,
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null,
referer: request.headers.get('referer'),
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
// Use a relative Location header so the browser resolves it against the
// public request URL. NextResponse.redirect(new URL('/', request.url))
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
});
}
+7 -5
View File
@@ -20,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = { function sessionCookieOptions() {
...getCookieOptions(), return {
maxAge: SESSION_COOKIE_MAX_AGE, ...getCookieOptions(),
}; maxAge: SESSION_COOKIE_MAX_AGE,
};
}
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
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, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, { setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
username, username,
+44 -11
View File
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto'; import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange'; import {
exchangeCodeForTokens,
getRequiredConfig,
getTokenEndpoint,
} from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
@@ -56,6 +60,10 @@ export async function POST(request: NextRequest) {
const codeVerifier = pending.code_verifier as string; const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string; const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null; const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
const mobileRedirectUri =
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
const isMobileFlow = Boolean(mobileRedirectUri);
if (!codeVerifier || !redirectUri) { if (!codeVerifier || !redirectUri) {
cookieStore.delete(SSO_PENDING_COOKIE); cookieStore.delete(SSO_PENDING_COOKIE);
@@ -65,21 +73,46 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens // Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// Store refresh token in the per-account cookie slot. // For the mobile handoff flow the tokens are handed back to the app
if (tokens.refresh_token) { // verbatim - we deliberately don't write any cookies on the webmail
const cookieName = refreshTokenCookieName(slot); // origin (the mobile browser tab disposes of the session after the
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); // redirect anyway, but the cookie would still get committed to the
} // user's main webmail session if they happened to be logged in there).
const serverCookieName = refreshTokenServerCookieName(slot); if (!isMobileFlow) {
if (pendingServerId) { if (tokens.refresh_token) {
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions()); const cookieName = refreshTokenCookieName(slot);
} else { cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
cookieStore.delete(serverCookieName); }
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
} else {
cookieStore.delete(serverCookieName);
}
} }
// Delete pending cookie // Delete pending cookie
cookieStore.delete(SSO_PENDING_COOKIE); cookieStore.delete(SSO_PENDING_COOKIE);
if (isMobileFlow) {
// The mobile client needs the bits it can't re-derive: the refresh
// token, the token endpoint it should hit to refresh later, and the
// client_id the IdP expects on that refresh call. The server URL is
// returned so the app knows which JMAP host to connect to.
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
token_endpoint: tokenEndpoint,
client_id: clientId,
server_url: serverUrl,
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
});
}
return NextResponse.json({ return NextResponse.json({
access_token: tokens.access_token, access_token: tokens.access_token,
expires_in: tokens.expires_in, expires_in: tokens.expires_in,
+32 -4
View File
@@ -5,25 +5,48 @@ import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server'; import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange'; import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret'; import { hasSessionSecret } from '@/lib/auth/session-secret';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes const SSO_PENDING_MAX_AGE = 300; // 5 minutes
// The mobile app's deep-link scheme. Only redirect targets starting with
// this prefix may flow through the mobile handoff path; without the guard
// the SSO complete route would be coerced into returning tokens to whatever
// caller-controlled URL the attacker chose.
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!hasSessionSecret()) { if (!hasSessionSecret()) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
} }
const { redirect_uri, locale, server_id: bodyServerId } = await request.json(); const {
redirect_uri,
locale,
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
} = await request.json();
if (!redirect_uri || typeof redirect_uri !== 'string') { if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 }); return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
} }
const mobileRedirectUri =
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
? rawMobileRedirectUri
: null;
const mobileState =
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
}
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Validate redirect_uri origin matches the request origin to prevent open redirects // Validate redirect_uri origin matches the request origin to prevent open redirects
@@ -39,7 +62,7 @@ export async function POST(request: NextRequest) {
} }
const { clientId, discoveryUrl } = getRequiredConfig(serverId); const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl); const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
if (!metadata?.authorization_endpoint) { if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
@@ -52,12 +75,17 @@ export async function POST(request: NextRequest) {
// Encrypt and store in httpOnly cookie. server_id is captured here so the // Encrypt and store in httpOnly cookie. server_id is captured here so the
// /complete handler reaches the same OAuth endpoint we used to authorize. // /complete handler reaches the same OAuth endpoint we used to authorize.
// Mobile params are captured here so /complete knows to return tokens to
// the caller (in the JSON response) instead of writing the usual server
// cookies - and so the callback page can redirect back to the app.
const pendingData = { const pendingData = {
state, state,
code_verifier: codeVerifier, code_verifier: codeVerifier,
redirect_uri, redirect_uri,
created_at: Date.now(), created_at: Date.now(),
...(serverId ? { server_id: serverId } : {}), ...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
}; };
const encrypted = encryptPayload(pendingData); const encrypted = encryptPayload(pendingData);
@@ -73,7 +101,7 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri); authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', OAUTH_SCOPES); authUrl.searchParams.set('scope', getOauthScopes());
authUrl.searchParams.set('state', state); authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('code_challenge_method', 'S256');
+17 -10
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, 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 { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
@@ -57,15 +57,22 @@ export async function POST(request: NextRequest) {
} }
const slot = getSlot(request, bodySlot); const slot = getSlot(request, bodySlot);
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller // Trusted (admin-configured) URLs skip the upstream re-fetch, but we
// just authenticated to JMAP with these credentials, and the cookie we // still bind the cookie's `username` to the credential when we can verify
// write here is only ever consumed for requests on behalf of this same // locally. Without this, a caller can POST username="admin@host" +
// user — a bogus auth header would just yield 401s downstream, not // authHeader=<their own Basic creds>, and downstream consumers that read
// privilege escalation. For untrusted custom endpoints we still verify // the cookie-derived username (audit logs, login tracker) accept the
// upstream as before. // spoof. Bearer tokens are opaque so only the format check runs;
const normalizedServerUrl = upstreamTrusted // authorization sinks must key off the credential itself, not the
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl)) // cookie's username claim (see admin/auth's authHeader-hashed cache key).
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); let normalizedServerUrl: string;
if (upstreamTrusted) {
validateProxyAuthHeader(authHeader);
assertBasicAuthMatchesUsername(authHeader, username);
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
} else {
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
}
await setStalwartAuthContext(slot, { await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
+1 -1
View File
@@ -54,7 +54,7 @@ async function tryTokenRequest(
async function findTokenEndpoint(serverUrl: string): Promise<string | null> { async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
// 1. Try OAuth discovery // 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl); const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
if (metadata?.token_endpoint) return metadata.token_endpoint; if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly // 2. Try common Stalwart token endpoint paths directly
+2
View File
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret'; import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
@@ -35,6 +36,7 @@ export async function GET() {
oauthOnly, oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''), oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''), oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(), rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(), settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled, stalwartFeaturesEnabled,
+47 -21
View File
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
// ===================================================================== // =====================================================================
{ {
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0), id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Willkommen bei Bulwark Webmail!', subject: 'Willkommen bei Bulwark Webmail!',
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.', preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
htmlBody: [], htmlBody: [],
bodyValues: { bodyValues: {
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' }, p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
}, },
}, },
{ {
@@ -197,7 +197,7 @@ const emails: MockEmail[] = [
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2), id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Sprint planning - next week priorities', subject: 'Sprint planning - next week priorities',
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.', preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
hasAttachment: false, hasAttachment: false,
@@ -367,7 +367,7 @@ const emails: MockEmail[] = [
}, },
{ {
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1), id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities', subject: 'Re: Sprint planning - next week priorities',
@@ -471,7 +471,7 @@ const emails: MockEmail[] = [
{ {
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5), id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
from: [{ name: 'Dev User', email: 'dev@localhost' }], from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [], to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
subject: 'Design review feedback', subject: 'Design review feedback',
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.', preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
hasAttachment: false, hasAttachment: false,
@@ -485,7 +485,7 @@ const emails: MockEmail[] = [
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5), id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
from: [{ name: 'Dev User', email: 'dev@localhost' }], from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities', subject: 'Re: Sprint planning - next week priorities',
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.', preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
hasAttachment: false, hasAttachment: false,
@@ -639,7 +639,7 @@ const emails: MockEmail[] = [
}, },
{ {
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30), id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Conference talk accepted!', subject: 'Conference talk accepted!',
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!', preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
@@ -728,8 +728,8 @@ const IDENTITIES = [
email: 'dev@localhost', email: 'dev@localhost',
replyTo: null, replyTo: null,
bcc: null, bcc: null,
textSignature: '-- \nDev User\nBulwark Webmail Developer', textSignature: 'Dev User\nBulwark Webmail Developer',
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>', htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
mayDelete: false, mayDelete: false,
}, },
]; ];
@@ -743,6 +743,12 @@ const addressBooks = [
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false }, { id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
]; ];
// Profile photos served straight from randomuser.me's CDN; the API at
// https://randomuser.me/api/ also returns these portrait URLs, but for a
// fixed mock dataset we link them directly to keep things offline-friendly.
// See https://randomuser.me/documentation#howto
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
const contacts = [ const contacts = [
// --- Personal address book --- // --- Personal address book ---
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -752,6 +758,7 @@ const contacts = [
organizations: { o1: { name: 'EuroTech GmbH' } }, organizations: { o1: { name: 'EuroTech GmbH' } },
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } }, addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } }, notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] }, name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
@@ -760,6 +767,7 @@ const contacts = [
organizations: { o1: { name: 'Dubois Consulting' } }, organizations: { o1: { name: 'Dubois Consulting' } },
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } }, addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } }, notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] }, name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
@@ -768,6 +776,7 @@ const contacts = [
organizations: { o1: { name: 'Rossi Design Studio' } }, organizations: { o1: { name: 'Rossi Design Studio' } },
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } }, addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } }, notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] }, name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
@@ -775,6 +784,7 @@ const contacts = [
phones: { p1: { number: '+31 20 555 0142' } }, phones: { p1: { number: '+31 20 555 0142' } },
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } }, notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] }, name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
@@ -783,6 +793,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } }, organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } }, addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } }, notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] }, name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
@@ -791,6 +802,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } }, organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } }, addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } }, notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] }, name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
@@ -798,6 +810,7 @@ const contacts = [
phones: { p1: { number: '+39 06 9876 5432' } }, phones: { p1: { number: '+39 06 9876 5432' } },
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } }, addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } }, notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] }, name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
@@ -806,6 +819,7 @@ const contacts = [
organizations: { o1: { name: 'BergLabs' } }, organizations: { o1: { name: 'BergLabs' } },
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } }, addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } }, notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] }, name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
@@ -814,6 +828,7 @@ const contacts = [
organizations: { o1: { name: 'Nielsen Konsult' } }, organizations: { o1: { name: 'Nielsen Konsult' } },
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } }, addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } }, notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] }, name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
@@ -822,6 +837,7 @@ const contacts = [
organizations: { o1: { name: 'Sorbonne Université' } }, organizations: { o1: { name: 'Sorbonne Université' } },
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } }, addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } }, notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
}, },
// --- Work address book --- // --- Work address book ---
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -831,6 +847,7 @@ const contacts = [
organizations: { o1: { name: 'Lefèvre & Associés' } }, organizations: { o1: { name: 'Lefèvre & Associés' } },
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } }, addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } }, notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] }, name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
@@ -839,6 +856,7 @@ const contacts = [
organizations: { o1: { name: 'Charité Klinik Berlin' } }, organizations: { o1: { name: 'Charité Klinik Berlin' } },
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } }, addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } }, notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] }, name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
@@ -847,6 +865,7 @@ const contacts = [
organizations: { o1: { name: 'Finanz Dublin' } }, organizations: { o1: { name: 'Finanz Dublin' } },
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } }, addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } }, notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] }, name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
@@ -855,6 +874,7 @@ const contacts = [
organizations: { o1: { name: 'García Design Studio' } }, organizations: { o1: { name: 'García Design Studio' } },
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } }, addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } }, notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] }, name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
@@ -863,6 +883,7 @@ const contacts = [
organizations: { o1: { name: 'Digitaal BV' } }, organizations: { o1: { name: 'Digitaal BV' } },
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } }, addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } }, notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] }, name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
@@ -871,6 +892,7 @@ const contacts = [
organizations: { o1: { name: 'Kowalska Marketing' } }, organizations: { o1: { name: 'Kowalska Marketing' } },
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } }, addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } }, notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] }, name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
@@ -879,6 +901,7 @@ const contacts = [
organizations: { o1: { name: 'Murphy Bau GmbH' } }, organizations: { o1: { name: 'Murphy Bau GmbH' } },
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } }, addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } }, notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] }, name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
@@ -887,6 +910,7 @@ const contacts = [
organizations: { o1: { name: 'Ferreira Media' } }, organizations: { o1: { name: 'Ferreira Media' } },
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } }, addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } }, notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] }, name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
@@ -895,6 +919,7 @@ const contacts = [
organizations: { o1: { name: 'Dumont Conseil' } }, organizations: { o1: { name: 'Dumont Conseil' } },
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } }, addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } }, notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] }, name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
@@ -904,6 +929,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } }, addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
nicknames: { n1: { name: 'Anni' } }, nicknames: { n1: { name: 'Anni' } },
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } }, notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
}, },
// --- Groups --- // --- Groups ---
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const, { id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
@@ -976,7 +1002,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
}, },
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } }, alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
@@ -986,7 +1012,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
}, },
@@ -1024,7 +1050,7 @@ const calendarEvents = [
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } }, virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Sophie Müller', 'sophie@eurotech.example'), p2: participant('Sophie Example', 'sophie@eurotech.example'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
}, },
description: 'Discuss API rate limit escalation for EuroTech enterprise account.', description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
@@ -1054,7 +1080,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
p6: participant('Pierre Dubois', 'pierre@dubois.example'), p6: participant('Pierre Dubois', 'pierre@dubois.example'),
@@ -1066,7 +1092,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost'), p1: participant('Dev User', 'dev@localhost'),
p2: participant('María García', 'maria@garcia-design.example', 'owner'), p2: participant('María García', 'maria@garcia-design.example', 'owner'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
}, },
}), }),
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', { makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
@@ -1084,7 +1110,7 @@ const calendarEvents = [
p2: participant('Dev User', 'dev@localhost'), p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'),
p5: participant('Sophie Müller', 'sophie@eurotech.example'), p5: participant('Sophie Example', 'sophie@eurotech.example'),
}, },
}), }),
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', { makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
@@ -1093,7 +1119,7 @@ const calendarEvents = [
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p4: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Sophie Example', 'sophie@eurotech.example'),
}, },
}), }),
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', { makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
@@ -1109,7 +1135,7 @@ const calendarEvents = [
location: 'Sophie\'s apartment, Kreuzberg, Berlin', location: 'Sophie\'s apartment, Kreuzberg, Berlin',
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.', description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
participants: { participants: {
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'), p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'), p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'),
@@ -1192,7 +1218,7 @@ const calendarEvents = [
}), }),
// ===== Birthday calendar (cal-5) ===== // ===== Birthday calendar (cal-5) =====
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', { makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
showWithoutTime: true, showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }], recurrence: [{ frequency: 'yearly' }],
description: 'Don\'t forget to bring Kuchen!', description: 'Don\'t forget to bring Kuchen!',
@@ -1220,7 +1246,7 @@ const calendarEvents = [
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!', description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
participants: { participants: {
p1: participant('Dev User', 'dev@localhost'), p1: participant('Dev User', 'dev@localhost'),
p2: participant('Sophie Müller', 'sophie@eurotech.example'), p2: participant('Sophie Example', 'sophie@eurotech.example'),
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
}, },
}), }),
+39 -6
View File
@@ -4,6 +4,26 @@ 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;
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}
let authHeader: string | null = null;
if (parsed.username || parsed.password) {
const username = decodeURIComponent(parsed.username);
const password = decodeURIComponent(parsed.password);
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
parsed.username = '';
parsed.password = '';
}
return { cleanUrl: parsed.toString(), authHeader };
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let body: { url?: string }; let body: { url?: string };
try { try {
@@ -18,7 +38,14 @@ 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 isPublicHttpUrl(url))) { const extracted = extractBasicAuth(url);
if (!extracted) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
const { cleanUrl, authHeader } = extracted;
if (!(await isPublicHttpUrl(cleanUrl))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
} }
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
let currentUrl = url; let currentUrl = cleanUrl;
const originalOrigin = new URL(cleanUrl).origin;
let response: Response | undefined; let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) { for (let i = 0; i <= MAX_REDIRECTS; i++) {
@@ -36,12 +64,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
} }
const headers: Record<string, string> = {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
};
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
headers['Authorization'] = authHeader;
}
response = await fetch(currentUrl, { response = await fetch(currentUrl, {
signal: controller.signal, signal: controller.signal,
headers: { headers,
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'manual', redirect: 'manual',
}); });
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { logger } from '@/lib/logger';
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
/**
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
*
* Any logged-in user may query the server-side approval state for a plugin
* they want to enable. The client uses this BEFORE running `enablePlugin`
* when the `requirePluginApproval` policy is set.
*
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
* pending approval entry (or returns the existing one).
*/
async function resolveUsername(): Promise<string | null> {
const cookieStore = await cookies();
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const sess = decryptSession(token);
if (sess?.username) return sess.username;
}
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx?.username) return ctx.username;
}
return null;
}
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
const status = await getApprovalStatus(pluginId, bundleHash);
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
const m = (b.manifest ?? {}) as Record<string, unknown>;
const manifest: ApprovalEntry['manifest'] = {
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
};
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
} catch (err) {
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
import { logger } from '@/lib/logger';
/**
* GET /api/plugin-signing-pubkey
*
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
* sandboxed plugin loader can verify bundle signatures before evaluation.
* Public - every logged-in user needs to fetch it on app boot.
*
* The response is long-cache-eligible (the key rotates only when an operator
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
* client caches the result in memory for the lifetime of the page.
*/
export async function GET() {
try {
const publicKey = await getPublicKeyBase64();
return NextResponse.json(
{ algorithm: 'ed25519', publicKey },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
}
}
+12 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
*/ */
export async function GET() { export async function GET() {
try { try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([ const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
@@ -34,7 +39,11 @@ export async function GET() {
type: p.type, type: p.type,
permissions: p.permissions, permissions: p.permissions,
entrypoint: p.entrypoint, entrypoint: p.entrypoint,
forceEnabled: p.forceEnabled || false, // Policy is the canonical source for force-enable. The per-plugin field
// can drift for dev plugins (manifest always loads forceEnabled:false)
// and during pending policy saves; OR'ing here unifies the signal so
// the client's auto-enable path triggers consistently.
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
// Content hash + updatedAt let clients detect re-uploads even when // Content hash + updatedAt let clients detect re-uploads even when
// the manifest version is unchanged. // the manifest version is unchanged.
bundleHash: p.bundleHash, bundleHash: p.bundleHash,
@@ -43,6 +52,8 @@ export async function GET() {
dev: p.dev, dev: p.dev,
// Surface so clients can enforce api.http.fetch origin allowlists. // Surface so clients can enforce api.http.fetch origin allowlists.
httpOrigins: p.httpOrigins, httpOrigins: p.httpOrigins,
// Surface so clients can enforce api.http.post path allowlists.
apiPostPaths: p.apiPostPaths,
// Per-user settings schema, captured from the manifest at upload/load // Per-user settings schema, captured from the manifest at upload/load
// time so the client can render the settings UI without re-parsing. // time so the client can render the settings UI without re-parsing.
settingsSchema: p.settingsSchema, settingsSchema: p.settingsSchema,
+91 -21
View File
@@ -1,10 +1,73 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import {
getStalwartCredentials,
type StalwartCredentials,
} from '@/lib/stalwart/credentials';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
interface ResolvedTarget {
authHeader: string;
apiUrl: string;
accountId: string;
}
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
// that account - not just "the first signed-in slot", which is what
// getStalwartCredentials() defaults to. Probe each candidate's session in
// parallel and return the first match.
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
const cookieStore = await cookies();
const probes: Promise<ResolvedTarget | null>[] = [];
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (!ctx) continue;
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
probes.push(
(async () => {
try {
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: ctx.authHeader },
});
if (!res.ok) return null;
const session = (await res.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!session.apiUrl || !mailAccountId) return null;
if (mailAccountId !== accountId) return null;
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
} catch {
return null;
}
})(),
);
}
const results = await Promise.all(probes);
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
}
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) return null;
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 null;
return { authHeader: creds.authHeader, apiUrl, accountId };
}
/** /**
* GET /api/push/preview * GET /api/push/preview
* *
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const creds = await getStalwartCredentials(request); // SW passes ?accountId=<jmap-account-id> derived from the push payload's
if (!creds) { // StateChange so multi-account browsers fetch from the right slot. Older
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); // clients (and the manual /api/push/preview probe) omit it and fall back
// to the first signed-in slot.
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
let target: ResolvedTarget | null = null;
let authHeader: string;
if (requestedAccountId) {
target = await resolveTargetForAccount(requestedAccountId);
if (!target) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
authHeader = target.authHeader;
} else {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
target = await resolveDefaultTarget(creds);
if (!target) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
authHeader = creds.authHeader;
} }
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, { const { apiUrl, accountId } = target;
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, { const inboxRes = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: creds.authHeader, Authorization: authHeader,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
const jmapRes = await fetch(apiUrl, { const jmapRes = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: creds.authHeader, Authorization: authHeader,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
+23 -6
View File
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp'; import sharp from 'sharp';
import path from 'node:path'; import path from 'node:path';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
const VALID_SIZES = new Set([192, 512]); const VALID_SIZES = new Set([192, 512]);
// Cache resized images in memory to avoid reprocessing on every request // Cache resized images keyed by (size, source URL) so admin re-uploads or URL
const cache = new Map<number, Blob>(); // changes invalidate the prior render instead of serving stale bytes forever.
const cache = new Map<string, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> { async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
// Absolute URL (http/https) // Absolute URL (http/https)
@@ -16,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
return Buffer.from(await res.arrayBuffer()); return Buffer.from(await res.arrayBuffer());
} }
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
// but stored on disk under getConfigDir()/branding/.
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
return readFile(path.join(getConfigDir(), 'branding', filename));
}
// Path relative to public/ directory // Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, '')); const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
return readFile(publicPath); return readFile(publicPath);
@@ -32,7 +43,11 @@ export async function GET(
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 }); return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
} }
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL; await configManager.ensureLoaded();
const sources = configManager.getAllWithSources();
const iconUrl =
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) { if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 }); return new NextResponse('No PWA icon configured', { status: 404 });
} }
@@ -42,9 +57,11 @@ export async function GET(
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}; };
const cacheKey = `${size}|${iconUrl}`;
try { try {
if (cache.has(size)) { if (cache.has(cacheKey)) {
return new NextResponse(cache.get(size)!, { headers: pngHeaders }); return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
} }
const sourceBuffer = await fetchSourceImage(iconUrl); const sourceBuffer = await fetchSourceImage(iconUrl);
@@ -56,7 +73,7 @@ export async function GET(
const ab = new ArrayBuffer(resized.byteLength); const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized); new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' }); const blob = new Blob([ab], { type: 'image/png' });
cache.set(size, blob); cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders }); return new NextResponse(blob, { headers: pngHeaders });
} catch (err) { } catch (err) {
+5 -5
View File
@@ -47,14 +47,14 @@ function sanitizeFilename(name: string): string {
} }
/** /**
* POST /api/setup/branding wizard branding upload. * POST /api/setup/branding - wizard branding upload.
* *
* Multipart form fields: * Multipart form fields:
* file the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB) * file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
* slot which branding key (faviconUrl, loginLogoLightUrl, etc.) * slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
* *
* Mirrors /api/admin/branding but authenticates via the wizard cookie * Mirrors /api/admin/branding but authenticates via the wizard cookie
* instead of admin session admin auth doesn't exist yet during bootstrap. * instead of admin session - admin auth doesn't exist yet during bootstrap.
* Files land in the same directory; the public read endpoint at * Files land in the same directory; the public read endpoint at
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded * /api/admin/branding/<filename> serves both wizard- and admin-uploaded
* assets after setup. * assets after setup.
@@ -126,7 +126,7 @@ export async function POST(request: NextRequest) {
} }
/** /**
* DELETE /api/setup/branding remove an uploaded asset and clear the * DELETE /api/setup/branding - remove an uploaded asset and clear the
* config override so the slot falls back to the system default. * config override so the slot falls back to the system default.
* *
* Body: { slot: string } * Body: { slot: string }
+8 -5
View File
@@ -58,13 +58,16 @@ export async function POST(request: NextRequest) {
} }
try { try {
// 1. Provision the admin account. Aborts cleanly if one already exists // 1. Provision the admin account. An admin.json file may already exist
// (defence in depth - should be impossible in bootstrap state). // from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
const created = await setInitialAdminPassword(adminPassword); // run while setupComplete is still false - accept the wizard's
// password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
if (!created) { if (!created) {
return NextResponse.json( return NextResponse.json(
{ error: 'Admin account already exists; cannot finish setup again' }, { error: 'Failed to write admin credentials' },
{ status: 409 }, { status: 500 },
); );
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
} }
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes(); const attrs = buildSessionCookieAttributes(request);
response.cookies.set(attrs.name, submitted, { response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly, httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite, sameSite: attrs.sameSite,
+15 -1
View File
@@ -171,8 +171,15 @@ body {
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-foreground); color: var(--color-foreground);
font-family: font-family:
system-ui,
-apple-system, -apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans Thai",
"Leelawadee UI",
Tahoma,
sans-serif; sans-serif;
font-feature-settings: font-feature-settings:
"rlig" 1, "rlig" 1,
@@ -233,6 +240,13 @@ body {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
@media (max-width: 640px) {
.email-content-text {
padding-left: 0;
padding-right: 0;
}
}
.email-content-text a { .email-content-text a {
color: #2563eb; color: #2563eb;
text-decoration: underline; text-decoration: underline;
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
<defs>
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
<style>
.icon-bg { fill: #ffffff; }
.icon-mark { fill: rgb(219,45,84); }
@media (prefers-color-scheme: dark) {
.icon-bg { fill: #18181b; }
}
</style>
</defs>
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
<rect width="1000" height="1000" class="icon-bg"/>
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

+36 -9
View File
@@ -1,29 +1,49 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { configManager } from "@/lib/admin/config-manager";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type WebAppProtocolHandler = {
protocol: string;
url: string;
};
type ExtendedManifest = MetadataRoute.Manifest & {
protocol_handlers?: WebAppProtocolHandler[];
launch_handler?: {
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
};
};
// Manifest paths must include the deployment subpath - browsers resolve them // Manifest paths must include the deployment subpath - browsers resolve them
// against the document origin, not the manifest's location, and Next.js does // against the document origin, not the manifest's location, and Next.js does
// not auto-prefix string literals inside MetadataRoute payloads. // not auto-prefix string literals inside MetadataRoute payloads.
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`; const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): MetadataRoute.Manifest { export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const appName = const appName =
process.env.APP_NAME || configManager.get<string>("appName") ||
process.env.NEXT_PUBLIC_APP_NAME || process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail"; "Bulwark Webmail";
const shortName = process.env.APP_SHORT_NAME || appName; const shortName = configManager.get<string>("appShortName") || appName;
const description = const description =
process.env.APP_DESCRIPTION || configManager.get<string>("appDescription") ||
"A modern webmail client built for Stalwart Mail Server"; "A modern webmail client built for Stalwart Mail Server";
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff"; const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff"; const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs // If pwaIconUrl or faviconUrl was explicitly configured (admin override or
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs. // env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL); // Otherwise fall back to the static Bulwark PNGs - sources marked "default"
// are the built-in placeholder paths and not real custom icons.
const sources = configManager.getAllWithSources();
const hasCustomIcon =
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [ ? [
@@ -57,5 +77,12 @@ export default function manifest(): MetadataRoute.Manifest {
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
], ],
protocol_handlers: [
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
],
launch_handler: {
client_mode: ["focus-existing", "navigate-new"],
},
}; };
} }
+214 -32
View File
@@ -2,19 +2,49 @@
import { useMemo, useState } 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, Users, Plus, Eraser, Palette } from "lucide-react"; import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, 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";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-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 { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-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';
/**
* Split a per-account calendar list into "owned" (the user's own) and
* "shared" sub-buckets, then group shared by the owning principal so each
* delegator gets its own sub-section.
*/
type AccountCalendarSplit = {
owned: Calendar[];
sharedGroups: { label: string; calendars: Calendar[] }[];
};
function splitAccountCalendars(list: Calendar[]): AccountCalendarSplit {
const owned: Calendar[] = [];
const sharedBuckets = new Map<string, { label: string; calendars: Calendar[] }>();
for (const cal of list) {
if (cal.isShared) {
const key = cal.accountId || cal.accountName || cal.id;
const bucket = sharedBuckets.get(key);
if (bucket) {
bucket.calendars.push(cal);
} else {
sharedBuckets.set(key, { label: cal.accountName || key, calendars: [cal] });
}
} else {
owned.push(cal);
}
}
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
}
interface CalendarSidebarPanelProps { interface CalendarSidebarPanelProps {
calendars: Calendar[]; calendars: Calendar[];
selectedCalendarIds: string[]; selectedCalendarIds: string[];
@@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps {
onSubscribe?: () => void; onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void; onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null; client?: IJMAPClient | null;
/**
* When true, render one collapsible section per connected local account,
* mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed
* by their `localAccountId` and the active account is shown first.
*/
multiAccountMode?: boolean;
} }
export function CalendarSidebarPanel({ export function CalendarSidebarPanel({
@@ -43,12 +79,18 @@ export function CalendarSidebarPanel({
onSubscribe, onSubscribe,
onEditSubscription, onEditSubscription,
client, client,
multiAccountMode,
}: 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 tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions); const allSubs = useCalendarStore((s) => s.icalSubscriptions);
const currentAccountId = client?.getAccountId();
const icalSubscriptions = useMemo(
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
[allSubs, currentAccountId],
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -65,6 +107,26 @@ export function CalendarSidebarPanel({
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>(); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null); const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
// Persisted across mounts so toggle state survives tab switches in the
// Pro shell (same key family as the mail sidebar's account collapse).
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem('calendar-sidebar-collapsed-accounts');
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch { return new Set(); }
});
const toggleAccountGroup = (key: string) => {
setCollapsedAccountGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
try { localStorage.setItem('calendar-sidebar-collapsed-accounts', JSON.stringify(Array.from(next))); } catch { /* */ }
return next;
});
};
const localAccounts = useAccountStore((s) => s.accounts);
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => { const sharedAccountGroups = useMemo(() => {
const shared = calendars.filter(c => c.isShared); const shared = calendars.filter(c => c.isShared);
@@ -79,6 +141,53 @@ export function CalendarSidebarPanel({
return Array.from(groups.values()); return Array.from(groups.values());
}, [calendars]); }, [calendars]);
/**
* Pro / multi-account grouping: every calendar bucketed by its owning
* local account. Active account comes first, then the rest in their
* account-store order. Calendars without a `localAccountId` (e.g. the
* birthday calendar) fall into a separate "other" bucket so they still
* render.
*/
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<string, Calendar[]>();
for (const cal of calendars) {
const key = cal.localAccountId || '__other__';
const list = byAccount.get(key) ?? [];
list.push(cal);
byAccount.set(key, list);
}
const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = [];
// Active account first.
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
ordered.push({
key: activeLocalAccountId,
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!),
});
byAccount.delete(activeLocalAccountId);
}
// Then the rest in account-store order so the layout matches the mail sidebar.
for (const acct of localAccounts) {
if (!byAccount.has(acct.id)) continue;
ordered.push({
key: acct.id,
label: acct.label || acct.email || acct.username,
split: splitAccountCalendars(byAccount.get(acct.id)!),
});
byAccount.delete(acct.id);
}
// Any leftover buckets (deleted accounts, untagged calendars).
for (const [key, list] of byAccount.entries()) {
const fallbackLabel = key === '__other__'
? t('my_calendars')
: list[0]?.accountName || key;
ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) });
}
return ordered;
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
const getSubscriptionForCalendar = (calendarId: string) => { const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId); return icalSubscriptions.find(s => s.calendarId === calendarId);
}; };
@@ -263,37 +372,110 @@ export function CalendarSidebarPanel({
)} )}
</button> </button>
)} )}
<div className="flex items-center justify-between mb-2 px-1 group"> {multiAccountMode && localAccountGroups.length > 0 ? (
{onCreateCalendar ? ( <>
<button {localAccountGroups.map((group, idx) => {
onClick={onCreateCalendar} const expanded = !collapsedAccountGroups.has(group.key);
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5" const isActive = group.key === activeLocalAccountId;
title={tMgmt('add_calendar')} const { owned, sharedGroups } = group.split;
> return (
{t('my_calendars')} <div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" /> <button
</button> onClick={() => toggleAccountGroup(group.key)}
) : ( className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> >
{t('my_calendars')} {expanded ? (
</h3> <ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)} ) : (
</div> <ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<div className="space-y-0.5"> )}
{personalCalendars.map(renderCalendarItem)} <User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
</div> <span className="text-xs font-semibold text-foreground/90 truncate">
{group.label}
{sharedAccountGroups.map((group) => ( </span>
<div key={group.accountName} className="mt-4"> {isActive && onCreateCalendar && (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5"> <span
<Share2 className="w-3 h-3" /> role="button"
{group.accountName} tabIndex={0}
</h3> onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
<div className="space-y-0.5"> onKeyDown={(e) => {
{group.calendars.map(renderCalendarItem)} if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onCreateCalendar();
}
}}
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
</span>
)}
</button>
{expanded && (
<div className="mt-1 pl-3">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t('my_calendars')}
</div>
<div className="space-y-0.5">
{owned.map(renderCalendarItem)}
</div>
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
<div className="space-y-0.5">
{sg.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</>
) : (
<>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<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>
</div> <div className="space-y-0.5">
))} {personalCalendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</>
)}
{renderCalendarMenu()} {renderCalendarMenu()}
</div> </div>
+25 -1
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns"; import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
selectedCalendarIds?: string[]; selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void; onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean; enableCalendarTasks?: boolean;
/** Show a burger button at the start that opens the (overlay) sidebar. */
onMenuClick?: () => void;
} }
export function CalendarToolbar({ export function CalendarToolbar({
@@ -45,6 +47,7 @@ export function CalendarToolbar({
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
enableCalendarTasks, enableCalendarTasks,
onMenuClick,
}: CalendarToolbarProps) { }: CalendarToolbarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const formatter = useFormatter(); const formatter = useFormatter();
@@ -115,11 +118,32 @@ export function CalendarToolbar({
return ( return (
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}> <div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
{onMenuClick && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* ── MOBILE TOOLBAR ── */} {/* ── MOBILE TOOLBAR ── */}
{isMobile && ( {isMobile && (
<div className="flex flex-col gap-1 px-2 py-2"> <div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */} {/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
)}
{onNavigateBack && ( {onNavigateBack && (
<button <button
onClick={onNavigateBack} onClick={onNavigateBack}
+190 -70
View File
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; 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 { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react"; import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays } from "date-fns"; import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -76,7 +76,65 @@ function buildDuration(startDate: Date, endDate: Date): string {
} }
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly"; type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
}
let alertRowSeq = 0;
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
alertRowSeq += 1;
return { id: `r${alertRowSeq}`, value, unit };
}
function alertRowToOffset(row: AlertRow): string | null {
if (row.unit === "at_time") return "PT0S";
const v = Math.max(0, Math.floor(row.value));
if (!Number.isFinite(v) || v <= 0) return null;
switch (row.unit) {
case "minutes": return `-PT${v}M`;
case "hours": return `-PT${v}H`;
case "days": return `-P${v}D`;
case "weeks": return `-P${v}W`;
}
}
function offsetToAlertRow(offset: string): AlertRow | null {
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
return newAlertRow(0, "at_time");
}
let m = offset.match(/^-?P(\d+)W$/);
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
m = offset.match(/^-?P(\d+)D$/);
if (m) return newAlertRow(parseInt(m[1], 10), "days");
m = offset.match(/^-?PT(\d+)H$/);
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
m = offset.match(/^-?PT(\d+)M$/);
if (m) {
const mins = parseInt(m[1], 10);
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
return newAlertRow(mins, "minutes");
}
return null;
}
function formatAlertRowLabel(
row: { value: number; unit: AlertUnit },
t: ReturnType<typeof useTranslations>
): string {
if (row.unit === "at_time") return t("alerts.at_time");
switch (row.unit) {
case "minutes": return t("alerts.minutes_before", { count: row.value });
case "hours": return t("alerts.hours_before", { count: row.value });
case "days": return t("alerts.days_before", { count: row.value });
case "weeks": return t("alerts.weeks_before", { count: row.value });
}
}
function formatDurationDisplay(minutes: number): string { function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`; if (minutes < 60) return `${minutes}min`;
@@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null; if (!event.alerts) return null;
const first = Object.values(event.alerts)[0]; const labels: string[] = [];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; for (const alert of Object.values(event.alerts)) {
const offset = first.trigger.offset; if (alert.trigger["@type"] !== "OffsetTrigger") continue;
if (offset === "PT0S") return t("alerts.at_time"); const row = offsetToAlertRow(alert.trigger.offset);
const minMatch = offset.match(/-?PT(\d+)M$/); if (!row) continue;
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) }); labels.push(formatAlertRowLabel(row, t));
const hourMatch = offset.match(/-?PT(\d+)H$/); }
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) }); if (labels.length === 0) return null;
const dayMatch = offset.match(/-?P(\d+)D/); return labels.join(", ");
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
} }
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
@@ -216,22 +272,36 @@ export function EventModal({
if (!event?.recurrenceRules?.length) return "none"; if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption; return event.recurrenceRules[0].frequency as RecurrenceOption;
}); });
const [alert, setAlert] = useState<AlertOption>(() => { const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
if (!event?.alerts) return "none"; const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
const first = Object.values(event.alerts)[0]; if (!event?.alerts) return [];
if (!first) return "none"; const rows: AlertRow[] = [];
if (first.trigger["@type"] === "OffsetTrigger") { for (const [id, alert] of Object.entries(event.alerts)) {
const offset = first.trigger.offset; // Preserve alerts we can't represent in this UI (absolute triggers,
if (offset === "PT0S") return "at_time"; // email actions, offsets with non-canonical shapes) so they survive a save.
const minMatch = offset.match(/-?PT(\d+)M$/); if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
if (minMatch) return minMatch[1] as AlertOption; preservedAlertsRef.current[id] = alert;
const hourMatch = offset.match(/-?PT(\d+)H$/); continue;
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption; }
const dayMatch = offset.match(/-?P(\d+)D/); const row = offsetToAlertRow(alert.trigger.offset);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption; if (!row) {
preservedAlertsRef.current[id] = alert;
continue;
}
rows.push(row);
} }
return "none"; return rows;
}); });
const addAlertRow = useCallback(() => {
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
}, []);
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}, []);
const removeAlertRow = useCallback((id: string) => {
setAlertRows((prev) => prev.filter((r) => r.id !== id));
}, []);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -400,17 +470,23 @@ export function EventModal({
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null; if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
} }
if (alert !== "none") { const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`; let alertIdx = 0;
data.alerts = { for (const row of alertRows) {
alert1: { const offset = alertRowToOffset(row);
"@type": "Alert", if (offset === null) continue;
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, let key = `alert${++alertIdx}`;
action: "display", while (key in builtAlerts) key = `alert${++alertIdx}`;
acknowledged: null, builtAlerts[key] = {
relatedTo: null, "@type": "Alert",
}, trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
}; };
}
if (Object.keys(builtAlerts).length > 0) {
data.alerts = builtAlerts;
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) { } else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
data.alerts = null; data.alerts = null;
} }
@@ -435,7 +511,7 @@ export function EventModal({
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return; if (!event || !userParticipantId || !onRsvp) return;
@@ -987,37 +1063,81 @@ export function EventModal({
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div>
<div> <label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label> <select
<select value={recurrence}
value={recurrence} onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)} 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"
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" >
> <option value="none">{t("recurrence.none")}</option>
<option value="none">{t("recurrence.none")}</option> <option value="daily">{t("recurrence.daily")}</option>
<option value="daily">{t("recurrence.daily")}</option> <option value="weekly">{t("recurrence.weekly")}</option>
<option value="weekly">{t("recurrence.weekly")}</option> <option value="monthly">{t("recurrence.monthly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option> <option value="yearly">{t("recurrence.yearly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option> </select>
</select> </div>
</div>
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label> <label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
<select {alertRows.length === 0 ? (
value={alert} <p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
onChange={(e) => setAlert(e.target.value as AlertOption)} ) : (
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" <div className="space-y-2">
> {alertRows.map((row) => (
<option value="none">{t("alerts.none")}</option> <div key={row.id} className="flex items-center gap-2">
<option value="at_time">{t("alerts.at_time")}</option> {row.unit !== "at_time" && (
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option> <Input
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option> type="number"
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option> min={1}
<option value="60">{t("alerts.hours_before", { count: 1 })}</option> max={999}
<option value="1440">{t("alerts.days_before", { count: 1 })}</option> value={row.value}
</select> onChange={(e) => {
</div> const n = parseInt(e.target.value, 10);
updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
}}
className="w-20"
aria-label={t("alerts.amount")}
/>
)}
<select
value={row.unit}
onChange={(e) => {
const unit = e.target.value as AlertUnit;
updateAlertRow(row.id, {
unit,
value: unit === "at_time" ? 0 : (row.value || 1),
});
}}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={t("alerts.unit")}
>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
<option value="hours">{t("alerts.unit_hours_before")}</option>
<option value="days">{t("alerts.unit_days_before")}</option>
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
</select>
<button
type="button"
onClick={() => removeAlertRow(row.id)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("alerts.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<button
type="button"
onClick={addAlertRow}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
</div> </div>
{attendees.length > 0 && ( {attendees.length > 0 && (
+4 -3
View File
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
calendars: Calendar[]; calendars: Calendar[];
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
initialUrl?: string;
} }
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing"; type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url"; type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) { export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
const t = useTranslations("calendar.import"); const t = useTranslations("calendar.import");
const tCal = useTranslations("calendar"); const tCal = useTranslations("calendar");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false); const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file"); const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
const [urlInput, setUrlInput] = useState(""); const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false); const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
editSubscription?: ICalSubscription; editSubscription?: ICalSubscription;
initialUrl?: string;
initialName?: string;
} }
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) { export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription"); const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
const isEdit = !!editSubscription; const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || ""); const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
const [name, setName] = useState(editSubscription?.name || ""); const [name, setName] = useState(editSubscription?.name || initialName || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6"); const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60); const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -76,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]); }, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
+22 -5
View File
@@ -6,6 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake
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 { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { normalizeContactPhotoUri } from "@/stores/contact-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
@@ -51,6 +52,8 @@ interface ContactFormProps {
addressBooks?: AddressBook[]; addressBooks?: AddressBook[];
allKeywords?: string[]; allKeywords?: string[];
defaultAddressBookId?: string; defaultAddressBookId?: string;
/** Prefills the create form (ignored when `contact` is set). */
prefill?: { email?: string; name?: string };
onSave: (data: Partial<ContactCard>) => Promise<void>; onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void; onCancel: () => void;
} }
@@ -144,10 +147,22 @@ function Select({ value, onChange, children, className }: {
); );
} }
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) { export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form"); const t = useTranslations("contacts.form");
const isEditing = !!contact; const isEditing = !!contact;
// Split a free-form display name into given/surname for prefill.
const prefillGivenName = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts[0] || "";
})();
const prefillSurname = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts.slice(1).join(" ");
})();
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) => const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || ""; contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
@@ -214,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
} }
const [prefix, setPrefix] = useState(findComponent("title", "prefix")); const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given")); const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle")); const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname")); const [surname, setSurname] = useState(findComponent("surname") || prefillSurname);
const [suffix, setSuffix] = useState(findComponent("generation", "suffix")); const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState( const [nickname, setNickname] = useState(
@@ -230,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "", context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
})); }));
} }
return [{ address: "", context: "" }]; return [{ address: prefill?.email || "", context: "" }];
}); });
const [phones, setPhones] = useState<PhoneEntry[]>(() => { const [phones, setPhones] = useState<PhoneEntry[]>(() => {
@@ -341,7 +356,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const initialPhotoEntry = useMemo(() => { const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null; if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) { for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType }; if (m.kind === "photo" && m.uri) {
return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType };
}
} }
return null; return null;
}, [contact]); }, [contact]);
+14 -1
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react"; import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
@@ -112,6 +112,8 @@ interface ContactListProps {
onEditContact: (id: string) => void; onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void; onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void; onAddContactToGroup: (id: string) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
} }
export function ContactList({ export function ContactList({
@@ -133,6 +135,7 @@ export function ContactList({
onEditContact, onEditContact,
onDeleteContact, onDeleteContact,
onAddContactToGroup, onAddContactToGroup,
onMenuClick,
}: ContactListProps) { }: ContactListProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const locale = useLocale(); const locale = useLocale();
@@ -267,6 +270,16 @@ export function ContactList({
<div className="border-b border-border bg-background"> <div className="border-b border-border bg-background">
<div className="px-3 py-3"> <div className="px-3 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{onMenuClick && (
<button
type="button"
onClick={onMenuClick}
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("open_categories")}
>
<Menu className="w-4 h-4" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
+186 -42
View File
@@ -2,7 +2,7 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react"; import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
@@ -10,6 +10,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; import { getContactDisplayName } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
onDeleteAddressBook?: (addressBook: AddressBook) => void; onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void; onRenameKeyword?: (keyword: string) => void;
className?: string; className?: string;
/**
* Pro shell: render one collapsible section per connected local account
* (active first), each with "My Address Books" / "Shared from X"
* subsections. Mirrors the calendar sidebar's Pro layout.
*/
multiAccountMode?: boolean;
}
type AddressBookAccountSplit = {
owned: AddressBook[];
sharedGroups: { label: string; books: AddressBook[] }[];
};
function splitAccountBooks(list: AddressBook[]): AddressBookAccountSplit {
const owned: AddressBook[] = [];
const sharedBuckets = new Map<string, { label: string; books: AddressBook[] }>();
for (const book of list) {
if (book.isShared) {
const key = book.accountId || book.accountName || book.id;
const bucket = sharedBuckets.get(key);
if (bucket) {
bucket.books.push(book);
} else {
sharedBuckets.set(key, { label: book.accountName || key, books: [book] });
}
} else {
owned.push(book);
}
}
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
} }
const COLLAPSED_KEY = "contacts-sidebar-collapsed"; const COLLAPSED_KEY = "contacts-sidebar-collapsed";
@@ -70,6 +101,7 @@ export function ContactsSidebar({
onDeleteAddressBook, onDeleteAddressBook,
onRenameKeyword, onRenameKeyword,
className, className,
multiAccountMode,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const router = useRouter(); const router = useRouter();
@@ -136,6 +168,47 @@ export function ContactsSidebar({
return Array.from(map.values()); return Array.from(map.values());
}, [addressBooks]); }, [addressBooks]);
// Pro / multi-account grouping: each local account is its own collapsible
// section with owned / shared sub-buckets.
const localAccounts = useAccountStore((s) => s.accounts);
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<string, AddressBook[]>();
for (const book of addressBooks) {
const key = book.localAccountId || '__other__';
const list = byAccount.get(key) ?? [];
list.push(book);
byAccount.set(key, list);
}
const ordered: { key: string; label: string; split: AddressBookAccountSplit }[] = [];
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
ordered.push({
key: activeLocalAccountId,
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
split: splitAccountBooks(byAccount.get(activeLocalAccountId)!),
});
byAccount.delete(activeLocalAccountId);
}
for (const acct of localAccounts) {
if (!byAccount.has(acct.id)) continue;
ordered.push({
key: acct.id,
label: acct.label || acct.email || acct.username,
split: splitAccountBooks(byAccount.get(acct.id)!),
});
byAccount.delete(acct.id);
}
for (const [key, list] of byAccount.entries()) {
const fallback = key === '__other__'
? t('address_books.title')
: list[0]?.accountName || key;
ordered.push({ key, label: fallback, split: splitAccountBooks(list) });
}
return ordered;
}, [multiAccountMode, addressBooks, localAccounts, activeLocalAccountId, t]);
// Count contacts per address book // Count contacts per address book
const contactCountByBook = useMemo(() => { const contactCountByBook = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
@@ -257,47 +330,117 @@ export function ContactsSidebar({
</span> </span>
</button> </button>
{/* My Address Books */} {/* Address Books: per-account groups in multi-account Pro mode, else the
{personalBooks.length > 0 && ( classic "My Address Books" section. */}
<div className="mt-2"> {multiAccountMode && localAccountGroups.length > 0 ? (
<div className="flex items-center px-3 py-1 group"> localAccountGroups.map((group) => {
<button const sectionKey = `account-${group.key}`;
onClick={() => toggleSection("addressBooks")} const expanded = !collapsed[sectionKey];
className="flex items-center gap-1 flex-1 text-left" const { owned, sharedGroups } = group.split;
> return (
{collapsed.addressBooks ? ( <div key={group.key} className="mt-2">
<ChevronRight className="w-3 h-3 text-muted-foreground" /> <div className="flex items-center px-3 py-1 group">
) : ( <button
<ChevronDown className="w-3 h-3 text-muted-foreground" /> onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
<User className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground/90 uppercase tracking-wider truncate">
{group.label}
</span>
</button>
</div>
{expanded && (
<div className="pl-2">
{owned.length > 0 && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t("address_books.title")}
</div>
{owned.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
{sg.books.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
)} )}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> </div>
{t("address_books.title")} );
</span> })
</button> ) : (
<button personalBooks.length > 0 && (
onClick={(e) => { <div className="mt-2">
e.stopPropagation(); <div className="flex items-center px-3 py-1 group">
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ } <button
router.push('/settings'); onClick={() => toggleSection("addressBooks")}
}} className="flex items-center gap-1 flex-1 text-left"
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted" >
title={t("address_books.manage")} {collapsed.addressBooks ? (
> <ChevronRight className="w-3 h-3 text-muted-foreground" />
<Settings className="w-3 h-3 text-muted-foreground" /> ) : (
</button> <ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div> </div>
{!collapsed.addressBooks && personalBooks.map((book) => ( )
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
)} )}
{/* Groups section */} {/* Groups section */}
@@ -398,8 +541,9 @@ export function ContactsSidebar({
)} )}
</div> </div>
{/* Shared accounts with address books */} {/* Shared accounts with address books - only when not already split
{sharedBookGroups.map((group) => ( into per-account groups above (multi-account Pro mode). */}
{!multiAccountMode && sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2"> <div key={group.accountId} className="mt-2">
<div className="flex items-center px-3 py-1 group"> <div className="flex items-center px-3 py-1 group">
<button <button
+47 -32
View File
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionNotice(null); setActionNotice(null);
setActionError(null); setActionError(null);
try { try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId); // JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
if (events.length > 0) { // is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in
const parsed = events[0]; // parallel with parsing to save a roundtrip.
setParsedEvent(parsed); const [events, rawText] = await Promise.all([
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST (async () => {
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback. try {
try { const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar'); return await blob.text();
const rawText = await blob.text(); } catch {
const icsMethod = extractMethodFromRawIcs(rawText); return null;
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
} }
} catch { /* ignore - fall back to heuristic detection */ } })(),
]);
if (parsed.uid && supportsCalendar) { if (events.length === 0) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
setState('error'); setState('error');
return;
}
const parsed = events[0];
setParsedEvent(parsed);
if (rawText) {
const icsMethod = extractMethodFromRawIcs(rawText);
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
}
}
setState('parsed');
// Hydrate the calendar store with the matching event in the background -
// only needed for the "already in calendar" pill, must not block the banner.
// Filter by UID server-side; the previous unfiltered query fetched up to
// 1000 events plus multiple /get batches just to find one match.
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
client.queryCalendarEvents({ uid: parsed.uid })
.then((matching) => {
if (matching.length === 0) return;
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
})
.catch(() => { /* ignore lookup failure */ });
}
} }
} catch { } catch {
setState('error'); setState('error');
+366 -53
View File
@@ -9,11 +9,12 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; 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 { sanitizeSignatureHtml } from "@/lib/email-sanitization";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; 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 { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -34,7 +35,12 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity"; import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string { function htmlToPlainText(html: string): string {
@@ -55,7 +61,7 @@ export interface ComposerDraftData {
mode: 'compose' | 'reply' | 'replyAll' | 'forward'; mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo']; replyTo?: EmailComposerProps['replyTo'];
draftId: string | null; draftId: string | null;
/** When set, overrides the header From: sent through the selected identity's envelope. */ /** When set, overrides the header From: - sent through the selected identity's envelope. */
fromOverrideEmail?: string; fromOverrideEmail?: string;
fromOverrideName?: string; fromOverrideName?: string;
fromOverrideEnabled?: boolean; fromOverrideEnabled?: boolean;
@@ -74,6 +80,11 @@ interface EmailComposerProps {
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
envelopeMailFrom?: string; envelopeMailFrom?: string;
/** Local account ID owning the selected identity. Set when the user
* picked an identity from a non-active account in the Pro multi-
* account dropdown; parents should send through that account's
* client instead of the currently-active one. */
localAccountId?: 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[]; inReplyTo?: string[];
references?: string[]; references?: string[];
@@ -102,6 +113,14 @@ interface EmailComposerProps {
messageId?: string; messageId?: string;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
// Pre-built quote header block. Supplied by the composer opener after it
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
// the composer uses these verbatim instead of building its own default
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
quoteHeaderHtml?: string;
quoteHeaderText?: string;
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
quoteWrapInBlockquote?: boolean;
}; };
} }
@@ -116,6 +135,39 @@ type ComposerAttachment = {
abortController?: AbortController; abortController?: AbortController;
}; };
type SignatureIdentityLike = {
htmlSignature?: string;
textSignature?: string;
} | null | undefined;
// Render the embedded signature for "above quote" mode. Bracketed with
// `data-signature-block` marker paragraphs so we can swap the inner content
// when the user switches identity without losing the surrounding draft or
// quoted message. The markers are preserved through TipTap by the
// StyledParagraph extension.
function buildEmbeddedSignatureHtml(
identity: SignatureIdentityLike,
options: { embed: boolean; separator: boolean }
): string {
if (!options.embed) return '';
const startMarker = options.separator
? `<p data-signature-block="separator">-- </p>`
: `<p data-signature-block="start"></p>`;
const endMarker = `<p data-signature-block="end"></p>`;
if (identity?.htmlSignature) {
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
}
if (identity?.textSignature) {
const escaped = identity.textSignature
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `${startMarker}<p>${escaped}</p>${endMarker}`;
}
return '';
}
export function EmailComposer({ export function EmailComposer({
onSend, onSend,
onClose, onClose,
@@ -136,8 +188,19 @@ export function EmailComposer({
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const identities = useIdentityStore((s) => s.identities); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const primaryIdentity = identities[0] ?? null; const activeIdentities = useIdentityStore((s) => s.identities);
// Pro shell: surface identities from every connected account, grouped
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
// the active account's identities only.
const multiAccountIdentities = useProMultiAccountIdentities();
const identities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: activeIdentities;
const identityGroups = multiAccountIdentities.enabled
? multiAccountIdentities.groups
: [];
const primaryIdentity = activeIdentities[0] ?? null;
// The signature identity used when embedding the signature into the initial // The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation // body for "above quote" mode. Mirrors the signatureIdentity derivation
@@ -204,12 +267,19 @@ export function EmailComposer({
// When "above quote" is configured, splice signature between the user's // When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a // drafting area and the quoted content so it reads naturally as a
// closing for the reply body. Send-time append is skipped see // closing for the reply body. Send-time append is skipped - see
// shouldEmbedSignatureAboveQuote. // shouldEmbedSignatureAboveQuote.
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
const signatureBlock = shouldEmbedSignatureAboveQuote const signatureBlock = shouldEmbedSignatureAboveQuote
? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}` ? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
: ''; : '';
// Plugin override (resolved at composer open via onBuildQuoteHeader).
if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const body = mode === 'forward' ? originalText : quotedText;
return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`;
}
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
@@ -225,28 +295,34 @@ export function EmailComposer({
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// When "above quote" is configured, splice signature between the user's const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
// drafting area and the quoted content so it reads naturally as a closing embed: shouldEmbedSignatureAboveQuote,
// for the reply body. Send-time append is skipped — see separator: signatureSeparatorEnabled,
// shouldEmbedSignatureAboveQuote. });
const buildEmbeddedSignatureHtml = (): string => {
if (!shouldEmbedSignatureAboveQuote) return ''; // Plugin override (resolved at composer open via onBuildQuoteHeader).
if (initialSignatureIdentity?.htmlSignature) { if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
return `<br><br>-- <br>${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`; const wrap = replyTo.quoteWrapInBlockquote !== false;
} const originalHtml = replyTo.htmlBody
if (initialSignatureIdentity?.textSignature) { ? rewriteCidImagesForEditor(replyTo.htmlBody)
return `<br><br>-- <br>${initialSignatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`; : (replyTo.body
} ? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
return ''; : '');
}; const bodyHtml = wrap
const signatureBlock = buildEmbeddedSignatureHtml(); ? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
: originalHtml;
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
}
// Build quoted content as HTML // Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const quoteHeader = mode === 'forward' const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>` ? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`; : `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`; // cid: image refs are rewritten so they render in the editor (browsers
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
} }
if (replyTo.body) { if (replyTo.body) {
@@ -268,9 +344,17 @@ export function EmailComposer({
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null); const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
// Mirror of draftId for synchronous reads inside chained saves; React's
// setDraftId is async, so a queued saveDraft would otherwise see the old
// value and try to destroy a draft that was just replaced.
const draftIdRef = useRef<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>(""); const lastSavedDataRef = useRef<string>("");
// Tracks the currently-running saveDraft so concurrent callers (autosave
// timer + send button) serialize instead of issuing parallel destroy/create
// requests with the same draftId. See bug #303.
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => { const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) { if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments return replyTo.attachments
@@ -329,12 +413,88 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity; : primaryIdentity;
// When the selected identity belongs to a non-active account (Pro
// multi-account dropdown), `currentIdentity.id` carries a "<localId>::"
// namespace and JMAP calls must be routed through that account's
// client with the un-prefixed id. `composerClient` and
// `currentIdentityRawId` are what save/send code should use.
const currentIdentityParts = currentIdentity?.id
? stripCrossAccountIdentityPrefix(currentIdentity.id)
: { localAccountId: null, rawId: undefined };
const composerClient = currentIdentityParts.localAccountId
? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client)
: client;
const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id;
// Alias identities often lack a configured signature - fall back to the primary // Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still // identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature. // populate the user's signature.
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
? currentIdentity ? currentIdentity
: primaryIdentity; : primaryIdentity;
// Hold the TipTap editor instance so we can swap the embedded signature
// when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft).
const editorRef = useRef<Editor | null>(null);
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
useEffect(() => {
const editor = editorRef.current;
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
if (!editor) return;
if (!identityChanged && !separatorChanged) return;
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (signaturePosition !== 'above_quote') return;
const currentHtml = editor.getHTML();
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
if (!startEl) return;
const endEl = doc.querySelector('[data-signature-block="end"]');
const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
embed: true,
separator: signatureSeparatorEnabled,
});
if (!newSignature) return;
// Build a temporary container holding the replacement nodes so we can
// splice them in without re-serializing/parsing twice.
const replacementHost = doc.createElement('div');
replacementHost.innerHTML = newSignature;
const replacementNodes = Array.from(replacementHost.childNodes);
const parent = startEl.parentNode;
if (!parent) return;
// Remove the existing signature range [startEl … endEl] inclusive, or
// from startEl to the next blockquote if no end marker is present.
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
const toRemove: Node[] = [];
let cursor: Node | null = startEl;
while (cursor) {
toRemove.push(cursor);
if (cursor === removeUntil) break;
const next: Node | null = cursor.nextSibling;
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
cursor = next;
}
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
toRemove.forEach((node) => parent.removeChild(node));
replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
const nextHtml = doc.body.innerHTML;
if (nextHtml !== currentHtml) {
editor.commands.setContent(nextHtml, { emitUpdate: true });
}
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
useEffect(() => { useEffect(() => {
if (!autoSelectReplyIdentity) return; if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return; if (selectedIdentityId || initialData?.selectedIdentityId) return;
@@ -382,8 +542,78 @@ export function EmailComposer({
selectedIdentityId, selectedIdentityId,
]); ]);
// Hydrate inline images referenced by the quoted body (issue #163).
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
// data-cid; here we (1) register each inline attachment in inlineImagesRef
// so the send path re-attaches the blob with the right cid, and (2) fetch
// each blob as a data URL and swap it into the body so the editor actually
// shows the image instead of a blank placeholder.
useEffect(() => {
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (!composerClient || !replyTo?.attachments?.length) return;
const inlineAtts = replyTo.attachments.filter((att) =>
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
);
if (inlineAtts.length === 0) return;
// Seed the ref synchronously so a fast Send still attaches the right blobs
// even if the FileReader work below hasn't resolved yet.
for (const att of inlineAtts) {
if (!att.cid) continue;
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
inlineImagesRef.current.push({
cid: att.cid,
blobId: att.blobId,
type: att.type,
name: att.name || 'inline',
size: att.size,
dataUrl: '',
});
}
let cancelled = false;
(async () => {
const updates = new Map<string, string>();
for (const att of inlineAtts) {
if (!att.cid) continue;
try {
const buffer = await composerClient.fetchBlobArrayBuffer(
att.blobId,
att.name || 'inline',
att.type,
);
if (cancelled) return;
const blob = new Blob([buffer], { type: att.type });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
if (cancelled) return;
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
if (entry) entry.dataUrl = dataUrl;
updates.set(att.cid, dataUrl);
} catch (err) {
debug.error('Failed to load inline image for compose', err);
}
}
if (cancelled || updates.size === 0) return;
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
})();
return () => {
cancelled = true;
};
// We deliberately hydrate once per composer open - subsequent replyTo
// object identity churn from parent renders shouldn't refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>` ? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature : signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>` ? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: ''; : '';
@@ -782,8 +1012,8 @@ export function EmailComposer({
}; };
// Auto-save draft functionality // Auto-save draft functionality
const saveDraft = async (): Promise<string | null> => { const saveDraftOnce = async (): Promise<string | null> => {
if (!client) return null; if (!client || !composerClient) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
@@ -808,7 +1038,7 @@ export function EmailComposer({
// Only save if data has changed // Only save if data has changed
if (currentData === lastSavedDataRef.current) { if (currentData === lastSavedDataRef.current) {
return draftId; return draftIdRef.current;
} }
setSaveStatus('saving'); setSaveStatus('saving');
@@ -828,20 +1058,27 @@ export function EmailComposer({
: (currentIdentity?.name || undefined); : (currentIdentity?.name || undefined);
try { try {
const savedDraftId = await client.createDraft( const previousDraftId = draftIdRef.current;
// Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
// identities. See `composerClient` derivation above.
const savedDraftId = await composerClient.createDraft(
toAddresses, toAddresses,
subject || t('no_subject'), subject || t('no_subject'),
plainTextMode ? body : htmlToPlainText(body), plainTextMode ? body : htmlToPlainText(body),
ccAddresses, ccAddresses,
bccAddresses, bccAddresses,
currentIdentity?.id, currentIdentityRawId,
fromEmail, fromEmail,
draftId || undefined, previousDraftId || undefined,
uploadedAttachments, uploadedAttachments,
fromName, fromName,
plainTextMode ? undefined : body plainTextMode ? undefined : body
); );
// Update the ref synchronously so a queued save sees the new id and
// doesn't try to destroy the just-replaced draft.
draftIdRef.current = savedDraftId;
setDraftId(savedDraftId); setDraftId(savedDraftId);
lastSavedDataRef.current = currentData; lastSavedDataRef.current = currentData;
setSaveStatus('saved'); setSaveStatus('saved');
@@ -858,6 +1095,28 @@ export function EmailComposer({
} }
}; };
// Serialize saves: each call waits for the previous in-flight save before
// running. This prevents the autosave timer and the send button from
// racing two `Email/set { destroy, create }` requests against the same
// draftId, which left orphan drafts and (when EmailSubmission failed)
// looked like "send didn't happen" (#303).
const saveDraft = (): Promise<string | null> => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise<string | null> => {
if (previous) {
try { await previous; } catch { /* prior failure already reported */ }
}
return saveDraftOnce();
})();
inflightSaveRef.current = promise;
promise.finally(() => {
if (inflightSaveRef.current === promise) {
inflightSaveRef.current = null;
}
});
return promise;
};
// Keep saveDraftRef pointing to latest saveDraft // Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft; saveDraftRef.current = saveDraft;
@@ -875,6 +1134,10 @@ 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(() => {
// Clear the ref so handleSend can distinguish "save scheduled" from
// "save in flight" - the former still needs flushing, the latter is
// tracked via inflightSaveRef.
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here. // Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({ emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean), to: to.split(',').map(s => s.trim()).filter(Boolean),
@@ -998,14 +1261,28 @@ export function EmailComposer({
} }
} }
let finalDraftId = draftId; // Resolve the freshest draftId we can. Two cases:
// 1. An autosave is currently in flight - wait for it; don't issue a
// parallel destroy/create that would race with it on the same id.
// 2. A debounced save is scheduled (timer set) - cancel it and flush
// now so the latest body content lands on the server.
// Use draftIdRef (not the React state) because state updates from
// the in-flight save may not have rendered yet when we read here.
let finalDraftId = draftIdRef.current;
if (inflightSaveRef.current) {
try {
const savedId = await inflightSaveRef.current;
if (savedId) finalDraftId = savedId;
} catch (err) {
debug.error('In-flight draft save failed before send:', err);
}
}
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
try { try {
const savedId = await saveDraft(); const savedId = await saveDraft();
if (savedId) { if (savedId) finalDraftId = savedId;
finalDraftId = savedId;
}
} catch (err) { } catch (err) {
debug.error('Failed to save draft before send:', err); debug.error('Failed to save draft before send:', err);
} }
@@ -1018,7 +1295,7 @@ export function EmailComposer({
: undefined; : undefined;
// When the user has typed a From override, that becomes the header From // When the user has typed a From override, that becomes the header From
// (and MIME-builder From in the S/MIME path). The identity still drives // (and MIME-builder From in the S/MIME path). The identity still drives
// the SMTP envelope MAIL FROM set explicitly so it doesn't mistakenly // the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
// default to the override address. // default to the override address.
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0; const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail; const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
@@ -1038,11 +1315,12 @@ export function EmailComposer({
// Build HTML signature block (used only in rich text mode) // Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => { const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return ''; if (signatureAlreadyInBody) return '';
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) { if (signatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
} }
if (signatureIdentity?.textSignature) { if (signatureIdentity?.textSignature) {
return `<br><br>-- <br>${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`; return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
} }
return ''; return '';
}; };
@@ -1053,9 +1331,10 @@ export function EmailComposer({
: null; : null;
// In plain text mode, send text/plain only (no HTML body) // In plain text mode, send text/plain only (no HTML body)
const signatureOpts = { separator: signatureSeparatorEnabled };
const finalBody = plainTextMode const finalBody = plainTextMode
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity)) ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity)); : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
const rewritten = plainTextMode ? null : rewriteInlineImages(body); const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode const finalHtmlBody = plainTextMode
@@ -1086,6 +1365,13 @@ export function EmailComposer({
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
// S/MIME keys are scoped to one JMAP account's identity - sending
// from a cross-account identity via S/MIME would mix accounts'
// certs/clients. Refuse upfront and tell the user to switch.
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
if (crossAccount.localAccountId) {
throw new Error('S/MIME sending from another accounts identity is not supported. Switch to that account first.');
}
// 1. Resolve S/MIME key // 1. Resolve S/MIME key
if (smimeSign_ && !smimeKeyRecord) { if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity'); throw new Error('No S/MIME key bound to this identity');
@@ -1094,7 +1380,7 @@ export function EmailComposer({
// would produce a signature whose Subject differs from the visible // would produce a signature whose Subject differs from the visible
// From, which most clients reject or flag. Refuse up front. // From, which most clients reject or flag. Refuse up front.
if (overrideActive) { if (overrideActive) {
throw new Error('Cannot use From override with S/MIME disable one to send.'); throw new Error('Cannot use From override with S/MIME - disable one to send.');
} }
// 2. Ensure key is unlocked for signing // 2. Ensure key is unlocked for signing
@@ -1231,6 +1517,15 @@ export function EmailComposer({
}; };
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// Strip the cross-account namespace from the identity id before
// handing it to the parent - the JMAP server only knows the raw
// id. The owning local account travels alongside so the parent
// can route the send through the right client.
const rawIdentityId = outgoing.identityId || currentIdentity?.id;
const { localAccountId: identityLocalAccountId, rawId } = rawIdentityId
? stripCrossAccountIdentityPrefix(rawIdentityId)
: { localAccountId: null, rawId: undefined };
await onSend?.({ await onSend?.({
to: outgoing.to, to: outgoing.to,
cc: outgoing.cc, cc: outgoing.cc,
@@ -1241,8 +1536,9 @@ export function EmailComposer({
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail, fromEmail,
fromName, fromName,
identityId: outgoing.identityId || currentIdentity?.id, identityId: rawId,
envelopeMailFrom, envelopeMailFrom,
localAccountId: identityLocalAccountId ?? undefined,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
@@ -1266,6 +1562,7 @@ export function EmailComposer({
setBcc(""); setBcc("");
setSubject(""); setSubject("");
setBody(""); setBody("");
draftIdRef.current = null;
setDraftId(null); setDraftId(null);
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({}); setValidationErrors({});
@@ -1411,16 +1708,31 @@ export function EmailComposer({
onChange={(e) => setSelectedIdentityId(e.target.value)} onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate" className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
> >
{identities.map((identity) => { {identityGroups.length > 0
const displayEmail = subAddressTag ? identityGroups.map((group) => (
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) <optgroup key={group.localAccountId} label={group.accountLabel}>
: identity.email; {group.identities.map((identity) => {
return ( const displayEmail = subAddressTag
<option key={identity.id} value={identity.id}> ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail} : identity.email;
</option> return (
); <option key={identity.id} value={identity.id}>
})} {identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</optgroup>
))
: identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select> </select>
) : ( ) : (
<span className="text-sm text-foreground flex-1 truncate"> <span className="text-sm text-foreground flex-1 truncate">
@@ -1628,6 +1940,7 @@ export function EmailComposer({
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')} placeholder={t('body_placeholder')}
hasError={validationErrors.body} hasError={validationErrors.body}
onEditorReady={(ed) => { editorRef.current = ed; }}
/> />
</div> </div>
)} )}
@@ -1638,13 +1951,13 @@ export function EmailComposer({
: plainTextMode ? ( : plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? ( getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono"> <div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
{'-- \n'}{getPlainTextSignature(signatureIdentity)} {signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
</div> </div>
) : null ) : null
) : composerSignatureHtml ? ( ) : composerSignatureHtml ? (
<div <div
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline" className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }} dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
/> />
) : null} ) : null}
</div> </div>
@@ -1702,7 +2015,7 @@ export function EmailComposer({
)} )}
{/* Bottom toolbar */} {/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0"> <div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
{/* Left side actions */} {/* Left side actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input

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