Compare commits

..
103 Commits
Author SHA1 Message Date
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
Linus Rath 869ee07ebc chore: bump next to 16.2.6 for security advisories 2026-05-11 20:11:05 +02:00
Linus Rath 2ad2bb1e09 chore: update version to 1.6.4 2026-05-11 20:00:55 +02:00
Linus Rath 23bc31c661 i18n: add missing translation keys across 15 locales 2026-05-11 19:30:52 +02:00
Linus Rath a2f76037a1 feat: update README and FEATURES.md 2026-05-11 19:24:32 +02:00
Linus Rath 9571f2e185 fix: skip upstream JMAP reverify for trusted URLs #237 2026-05-11 19:22:37 +02:00
Linus Rath 887b9c728c feat: drag attachments out to local file system #267 2026-05-11 17:34:24 +02:00
Linus Rath 8c21f462c2 feat: add signature position to email behavior settings search 2026-05-11 17:07:24 +02:00
Linus Rath 5f3d2d3e4a feat: signature above quoted text option #266 2026-05-11 17:05:59 +02:00
Linus Rath 4bce80b8ba feat: show avatar in Focused list for compact density and above 2026-05-11 16:23:15 +02:00
Linus Rath 1d09f5a623 feat: align Focused list preview with other layout previews 2026-05-11 15:41:25 +02:00
Linus Rath 2c513129f2 feat: add Reading Pane at Bottom mail layout #262 2026-05-11 15:35:43 +02:00
Linus Rath b3dc2e32b8 feat: implement prefetching of initial email data 2026-05-11 15:17:30 +02:00
Augustin MarcinandLinus Rath b0640c9ecc feat(compose): From override + catch-all auto-reply (fixes #246)
Adds an Override toggle in the composer's From row. When enabled, name
and address become free-text inputs. Mail is still submitted through the
selected identity, but the outgoing message's From: header — and the
SMTP envelope MAIL FROM when different — is set from the override.

The existing "Auto-select Reply Address" setting is extended: if the
incoming message was addressed to an alias on a domain that matches one
of your identities but isn't itself an identity (classic domain catch-
all), it now auto-enables Override and pre-fills the alias. Quick reply
honors the same resolution. The setting is relabeled to reflect the
broader behavior.

JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the
EmailSubmission includes an explicit envelope with that mailFrom and the
to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3).

S/MIME: override is incompatible with sign/encrypt and is refused with a
clear error — signing a different visible From from the identity's
certificate Subject would produce messages clients reject.

Tests: resolveReplyFrom covers exact match, sub-address stripping,
catch-all detection, identity preference, and foreign-domain null.
2026-05-11 12:13:47 +02:00
Linus Rath 2d7e24b513 perf: parallelize login round-trips and drop redundant JMAP re-verify 2026-05-11 11:04:13 +02:00
Linus Rath 5b30bacf10 feat: redesign review step with grouped summary and advanced toggle 2026-05-10 01:09:00 +02:00
Linus Rath fe937403f3 style: consistent notice cards for server probe results 2026-05-09 21:43:04 +02:00
Linus Rath 876ea370e4 feat: allow file uploads on the wizard branding step 2026-05-09 21:40:39 +02:00
Linus Rath 1dcdeeae86 style: consistent notice cards for server probe results 2026-05-09 18:12:43 +02:00
Linus Rath 01302a775c feat: require explicit confirmation when JMAP probe finds no session 2026-05-09 17:52:44 +02:00
Linus Rath 76d78ae756 fix: drop redundant first-login banner about removing ADMIN_PASSWORD #222 2026-05-09 17:40:53 +02:00
Linus Rath 51745ea03d feat: web setup wizard + admin config/state dir split (#226) 2026-05-09 17:37:41 +02:00
ChanceandLinus Rath c44a9ce6e0 fix: fall back to primary identity signature on reply
When auto-select picks an alias identity matching the original recipient,
the alias often has no signature configured. The composer was using the
alias's empty signature for both the visual preview and the appended
signature on send, so neither showed up. New mail worked because no
auto-select runs.

Add a signatureIdentity that falls back to the primary when the current
identity has no signature. From address, identity ID, S/MIME, and draft
saves still use currentIdentity so mail goes out from the right address.
2026-05-09 14:21:05 +02:00
Linus Rath 7fa65796f0 fix: show account identity in switcher header instead of sending alias 2026-05-09 13:21:13 +02:00
Linus Rath d09df7e8a3 fix: remove benchmark directory from .gitignore 2026-05-09 13:13:08 +02:00
Linus Rath 090399a308 chore: update version to 1.6.3 2026-05-08 21:10:21 +02:00
Linus Rath c31a58af1a i18n: add missing translation keys across 15 locales 2026-05-08 21:07:14 +02:00
Linus Rath 65aabb943c fix: fall back when only truncation indicator remains in email preview 2026-05-08 20:29:16 +02:00
Linus Rath 9c8739c4bb fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:27:03 +02:00
Linus Rath 48f72be209 fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:26:17 +02:00
Linus Rath 92fb0c63e9 fix: trim leading whitespace from email list preview 2026-05-08 20:19:25 +02:00
Linus Rath 55596556ef feat: import .eml files via folder right-click menu 2026-05-08 20:12:33 +02:00
Linus Rath abd63d124f fix: add benchmark directory to ESLint ignore list 2026-05-08 19:56:21 +02:00
Linus Rath 562080b7a3 fix: request shareWith explicitly so calendar/address book shares survive a re-login #257 2026-05-08 19:53:32 +02:00
Linus Rath 41c9f4926c fix: prevent white-on-white in dark mode for nested bgcolor containers 2026-05-08 02:49:58 +02:00
Linus Rath e7e78072d4 fix: render plain-text-only emails as text, not HTML 2026-05-07 17:49:39 +02:00
Linus Rath cd363b4840 fix: strip leading punctuation when computing avatar initials 2026-05-07 17:43:02 +02:00
Linus Rath 3a350c14a6 fix: hide files/contacts nav items when JMAP server lacks support 2026-05-07 17:21:47 +02:00
Linus Rath b0765bf085 style: redesign quick reply to match sender/banner layout 2026-05-07 17:02:06 +02:00
Linus Rath 9225ba0790 fix: render HTML-only emails and redesign external content prompt 2026-05-07 16:10:26 +02:00
Linus Rath 3b36738192 fix: pad Word/Outlook HTML email rendering 2026-05-07 15:42:47 +02:00
Linus Rath 3edd35ab57 fix: hide email hover actions on mobile 2026-05-07 15:23:05 +02:00
Linus Rath a86a96e390 fix: disable StarterKit's bundled link/underline to avoid duplicate extensions 2026-05-07 12:33:51 +02:00
Linus Rath 5f464d4ee2 feat: lift 5-account cap on HTTP/2 2026-05-07 12:28:33 +02:00
174 changed files with 14250 additions and 1523 deletions
+21 -4
View File
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Admin Dashboard Data # Admin Dashboard Data
# ============================================================================= # =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash, # Admin data is split across two directories so the config volume can be
# installed plugins/themes, and audit logs (default: ./data/admin). # mounted read-only after the setup wizard completes (see issue #226).
# For Docker, the default resolves to /app/data/admin - mount a persistent #
# volume there (see docker-compose.yml). # Config dir - operator-authored state. Holds config.json, policy.json,
# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
# branding uploads. Safe to mount read-only after setup.
# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
# ADMIN_CONFIG_DIR=./data/admin
#
# State dir - runtime mutations. Holds admin-state.json (login timestamps),
# audit.log, and the bootstrap setup token. Always read-write.
# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
# is set, for back-compat with single-volume installs)
# ADMIN_STATE_DIR=./data/admin-state
#
# Set to "true" to enforce read-only mode at the application layer (cleaner
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
# ADMIN_CONFIG_READONLY=true
#
# Legacy: a single dir containing both config and state. Honoured if neither
# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin # ADMIN_DATA_DIR=./data/admin
# ============================================================================= # =============================================================================
+1 -1
View File
@@ -49,4 +49,4 @@ next-env.d.ts
/local-data/ /local-data/
# Sibling repos # Sibling repos
/repos/ /repos/
+149
View File
@@ -1,5 +1,154 @@
# Changelog # Changelog
## 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)
### Web Setup Wizard
First-launch web setup wizard. New installs no longer need to hand-edit `.env.local` - point a browser at the container and the wizard probes the JMAP server(s), configures OAuth/OIDC, generates the session secret, accepts branding uploads, and provisions the initial admin password. Admin storage is now split into `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps); the legacy `ADMIN_DATA_DIR` keeps working for existing installs.
### Features
- **Setup**: Web setup wizard with multi-step flow: Server, Auth, Security, Logging, Branding, Review, Admin
- **Setup**: Admin config/state directory split with optional `ADMIN_CONFIG_READONLY` for immutable deployments (#226)
- **Setup**: File uploads on the wizard branding step
- **Setup**: Redesigned review step with grouped summary and an advanced toggle for the full config
- **Setup**: Require explicit confirmation when JMAP probe finds no session
- **Mail**: Drag attachments out of the viewer to the local file system (#267)
- **Mail**: Reading Pane at Bottom mail layout (#262)
- **Mail**: Configurable signature position - above or below quoted text (#266)
- **Mail**: Signature position is now searchable from the email behavior settings
- **Mail**: Show avatar in Focused list for compact density and above
- **Mail**: Align Focused list preview with other layout previews
- **Compose**: From-header override in the composer with catch-all auto-reply, replies to an alias on a domain you own pre-fill the alias as the sender even when it isn't a configured identity (#246)
### Performance
- **Mail**: Prefetch initial email data on login
- **Auth**: Parallelize login round-trips and drop redundant JMAP re-verify
### Fixes
- **Auth**: Skip upstream JMAP reverify for trusted URLs (#237)
- **Auth**: Show account identity in the switcher header instead of the sending alias
- **Compose**: Fall back to the primary identity signature on reply
- **Setup**: Drop redundant first-login banner about removing `ADMIN_PASSWORD` (#222)
- **UI**: Consistent notice cards for server probe results
### i18n
- Add missing translation keys across 15 locales
## 1.6.3 (2026-05-08)
### Features
- **Mail**: Lift 5-account cap on HTTP/2
- **Mail**: Import `.eml` files via folder right-click menu
### Fixes
- **Mail**: Trim leading whitespace from email list preview
- **Mail**: Fall back when only the truncation indicator remains in email preview
- **Mail**: Hide files/contacts nav items when JMAP server lacks support
- **Viewer**: Preserve emoji colors in dark mode
- **Viewer**: Prevent white-on-white in dark mode for nested `bgcolor` containers
- **Viewer**: Render plain-text-only emails as text, not HTML
- **Viewer**: Render HTML-only emails and redesign external content prompt
- **Viewer**: Pad Word/Outlook HTML email rendering
- **Compose**: Redesign quick reply to match sender/banner layout
- **Compose**: Disable StarterKit's bundled link/underline to avoid duplicate extensions
- **Sharing**: Request `shareWith` explicitly so calendar/address book shares survive a re-login (#257)
- **UI**: Strip leading punctuation when computing avatar initials
- **Mobile**: Hide email hover actions
### i18n
- Add missing translation keys across 15 locales
## 1.6.2 (2026-05-06) ## 1.6.2 (2026-05-06)
### Features ### Features
+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
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+24 -13
View File
@@ -2,19 +2,23 @@
## Mail ## Mail
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding) - Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle - Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts - Unified mailbox view across all connected accounts
- Draft auto-save with identity preservation - Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Attachment upload, download, and inline preview; forgotten-attachment warning - Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries - Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag - Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month - Archive modes direct, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment - Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay - Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes - Virtual scrolling for large mailboxes plus prefetching of initial email data on login
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers - Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- Plain-text composer mode and Reply-To support - Plain-text composer mode and Reply-To support
- Configurable signature position (above or below quoted text) per identity
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
- `.eml` file import via folder right-click menu
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping - TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts - Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer - Print directly from the viewer
@@ -79,7 +83,7 @@
## Interface ## Interface
- Three-pane layout with resizable columns - Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
- Dark and light themes with intelligent email color transformation - Dark and light themes with intelligent email color transformation
- Responsive desktop, tablet, and mobile layouts - Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation - Full keyboard navigation
@@ -94,31 +98,38 @@
## 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`.
## Identity & Multi-Account ## Identity & Multi-Account
- Up to 5 simultaneous accounts with instant switching and per-account session persistence - Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
- Account switcher with connection status and default account selection - Account switcher with connection status and default account selection
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list - Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions - Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts - Shared folders across accounts
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) - Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
## Admin & Extensibility ## Admin & Extensibility
- Stalwart admin dashboard with dedicated policy sections - Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement - Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles - Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) - Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
- Bundled plugins including Jitsi Meet calendar integration - Bundled plugins including Jitsi Meet calendar integration
## Operations ## Operations
- Progressive Web App with service worker, install prompt, and dynamic manifest - Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
- Automatic update check with server-side logging of new releases - Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels - Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- Release (`main`) and development (`dev`) Docker images on GHCR - Release (`main`) and development (`dev`) Docker images on GHCR
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required - Demo mode with fixture data no mail server required
+45 -12
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.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.6.7-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/)
@@ -20,6 +20,29 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
--- ---
## Installer
New in **1.6.4**: a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
</picture>
Point a browser at the running container and the wizard guides you through:
- **Server** probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle
- **Auth** OAuth2 / OIDC discovery and validation, or basic-auth fallback
- **Security** generate or paste a `SESSION_SECRET`, opt into settings sync
- **Logging** text or JSON, level
- **Branding** upload favicon, app logos, login logos, and company / legal URLs
- **Review** grouped summary with an advanced toggle for the full config
- **Admin** set the initial admin password and optionally drop a `.config-locked` marker so the config volume can be remounted read-only
The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JMAP_SERVER_URL` in the environment skips the wizard and uses env-managed configuration instead.
---
## Screenshots ## Screenshots
<picture> <picture>
@@ -63,7 +86,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos
- **Contacts** multiple address books, groups, vCard import/export - **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload - **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard. Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**. Full feature list: **[FEATURES.md](FEATURES.md)**.
@@ -74,28 +97,25 @@ Full feature list: **[FEATURES.md](FEATURES.md)**.
### Docker ### Docker
```bash ```bash
docker run -d -p 3000:3000 \ docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
-e JMAP_SERVER_URL=https://mail.example.com \
ghcr.io/bulwarkmail/webmail:latest
``` ```
Or with Docker Compose: Or with Docker Compose:
```bash ```bash
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
On first launch, open `http://localhost:3000` the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
### From Source ### From Source
```bash ```bash
git clone https://github.com/bulwarkmail/webmail.git git clone https://github.com/bulwarkmail/webmail.git
cd webmail cd webmail
npm install npm install
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
npm run build && npm start npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard
``` ```
### Development ### Development
@@ -108,13 +128,13 @@ npm run lint
## Configuration ## Configuration
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env ```env
# Required # Optional overrides whatever the wizard writes
JMAP_SERVER_URL=https://mail.example.com JMAP_SERVER_URL=https://mail.example.com
# Optional
APP_NAME=My Webmail APP_NAME=My Webmail
``` ```
@@ -218,6 +238,19 @@ LOG_LEVEL=info # error | warn | info | debug
</details> </details>
<details>
<summary>Admin data directories</summary>
```env
ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/
ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token
ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer
```
The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`.
</details>
<details> <details>
<summary>Subpath / reverse proxy mount</summary> <summary>Subpath / reverse proxy mount</summary>
+1 -1
View File
@@ -1 +1 @@
1.6.2 1.6.6
+156 -7
View File
@@ -15,6 +15,7 @@ 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 { useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -31,12 +32,14 @@ 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";
@@ -56,6 +59,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 +73,10 @@ 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 { 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 +102,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 +166,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,6 +177,84 @@ 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(() => {
if (!isAuthenticated || !client) return;
const openPendingWebcal = () => {
const pending = consumePendingWebcal();
if (!pending) return;
handleWebcalProtocolRequest(pending);
};
openPendingWebcal();
return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
useEffect(() => { useEffect(() => {
if (client && !hasFetched.current) { if (client && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
@@ -955,7 +1043,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,7 +1217,9 @@ export default function CalendarPage() {
}; };
return ( return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}> <div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */} {/* Left Navigation Rail */}
{!isMobile && ( {!isMobile && (
<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)' }}>
@@ -1378,14 +1515,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 +1548,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 +1583,7 @@ export default function CalendarPage() {
/> />
); );
})()} })()}
</div>
</div> </div>
); );
} }
+5 -1
View File
@@ -15,6 +15,7 @@ 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";
@@ -649,7 +650,9 @@ export default function ContactsPage() {
}; };
return ( return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}> <div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */} {/* Navigation Rail - desktop only */}
{!isMobile && ( {!isMobile && (
<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)' }}>
@@ -882,6 +885,7 @@ export default function ContactsPage() {
/> />
); );
})()} })()}
</div>
</div> </div>
); );
} }
+5 -1
View File
@@ -24,6 +24,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() {
@@ -374,7 +375,9 @@ export default function FilesPage() {
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && ( {!isMobile && (
<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
@@ -514,6 +517,7 @@ export default function FilesPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div>
</div> </div>
); );
} }
+4 -1
View File
@@ -5,6 +5,7 @@ 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 { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -32,7 +33,9 @@ export default async function LocaleLayout({
<RateLimitToastProvider> <RateLimitToastProvider>
<EmbeddedBridgeProvider> <EmbeddedBridgeProvider>
<TourProvider> <TourProvider>
{children} <ProtocolLaunchHandlerProvider>
{children}
</ProtocolLaunchHandlerProvider>
</TourProvider> </TourProvider>
</EmbeddedBridgeProvider> </EmbeddedBridgeProvider>
</RateLimitToastProvider> </RateLimitToastProvider>
+2 -3
View File
@@ -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";
@@ -117,7 +116,7 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account"; const isAddAccountMode = searchParams.get("mode") === "add-account";
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({
@@ -532,7 +531,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");
+234 -53
View File
@@ -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";
@@ -53,12 +54,17 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store"; import { usePluginStore } from "@/stores/plugin-store";
import { 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";
@@ -73,6 +79,7 @@ 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);
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();
@@ -88,8 +95,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);
@@ -202,7 +211,7 @@ export default function Home() {
// Mobile/tablet responsive hooks // Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection(); const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
const { const {
emails, emails,
mailboxes, mailboxes,
@@ -307,6 +316,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).
@@ -636,6 +652,7 @@ export default function Home() {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth); if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth); if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
if (parsed.emailListHeight) setEmailListHeight(parsed.emailListHeight);
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -649,7 +666,78 @@ export default function Home() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Load mailboxes and emails when authenticated (only if not already loaded) 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
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
// populates mailboxes before this effect first runs, so on the post-login
// path this block is a no-op.
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) { if (isAuthenticated && client && mailboxes.length === 0) {
let retryTimer: ReturnType<typeof setTimeout> | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -657,18 +745,14 @@ export default function Home() {
const loadData = async (attempt = 1) => { const loadData = async (attempt = 1) => {
try { try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([ await Promise.all([
fetchMailboxes(client), fetchMailboxes(client),
fetchQuota(client) fetchQuota(client)
]); ]);
// Get the selected mailbox (should be inbox by default)
const state = useEmailStore.getState(); const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox; const selectedMailboxId = state.selectedMailbox;
// On first login the server may still be provisioning mailboxes.
// Retry a few times with back-off before giving up.
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) { if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000); const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
@@ -676,34 +760,13 @@ export default function Home() {
return; return;
} }
// Fetch emails for the selected mailbox
if (selectedMailboxId) { if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId); await fetchEmails(client, selectedMailboxId);
} else { } else {
await fetchEmails(client); await fetchEmails(client);
} }
// Fetch tag counts
fetchTagCounts(client); fetchTagCounts(client);
// Setup push notifications after successful data load
try {
// Register state change callback
client.onStateChange((change) => handleStateChange(change, client));
// Start receiving push notifications
const pushEnabled = client.setupPushNotifications();
if (pushEnabled) {
setPushConnected(true);
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('push', '[Push] Push notifications not available on this server');
}
} catch (error) {
// Push notifications are optional - don't break the app if they fail
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
} catch (error) { } catch (error) {
console.error('Error loading email data:', error); console.error('Error loading email data:', error);
} }
@@ -713,17 +776,33 @@ export default function Home() {
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
}; };
} }
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
// Cleanup push notifications on unmount // Push notifications: set up once per client and tear down when the client
return () => { // goes away (logout or account switch). Kept separate from the fetch effect
if (client) { // above so it still runs when data was prefetched at login time.
client.closePushNotifications(); useEffect(() => {
if (!isAuthenticated || !client) return;
try {
client.onStateChange((change) => handleStateChange(change, client));
const pushEnabled = client.setupPushNotifications();
if (pushEnabled) {
setPushConnected(true);
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('push', '[Push] Push notifications not available on this server');
} }
} catch (error) {
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
return () => {
client.closePushNotifications();
}; };
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); }, [isAuthenticated, client, handleStateChange, setPushConnected]);
// 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
@@ -875,6 +954,7 @@ export default function Home() {
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
envelopeMailFrom?: 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[];
@@ -885,7 +965,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; const originalEmailId = selectedEmail?.id;
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references); await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.envelopeMailFrom);
setShowComposer(false); setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword // Mark the original email with $answered or $forwarded keyword
@@ -1504,6 +1584,43 @@ export default function Home() {
} }
}; };
const handleImportEmailFromContextMenu = (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const targetMailboxId = mailbox.originalId || mailbox.id;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.eml,message/rfc822';
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return;
let imported = 0;
let failed = 0;
for (const file of files) {
try {
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
imported++;
} catch {
failed++;
}
}
if (imported > 0) {
toast.success(t('notifications.import_email_success'));
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
}
if (failed > 0) {
toast.error(t('notifications.import_email_error'));
}
};
input.click();
};
const handleRefreshMailboxes = async () => { const handleRefreshMailboxes = async () => {
if (!client) return; if (!client) return;
try { try {
@@ -1604,9 +1721,30 @@ export default function Home() {
} }
const primaryIdentity = identities[0]; const primaryIdentity = identities[0];
const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity;
// Append signature from the primary identity // Decide the sending identity and (for domain-catch-all) an optional
const finalBody = appendPlainTextSignature(body, primaryIdentity); // header From override that matches the address the message was sent to.
// When the setting is off, fall through to primary-identity behavior.
const resolved = autoSelectReplyIdentity
? resolveReplyFrom(identities, {
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
})
: null;
const sendingIdentity = resolved
? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity)
: primaryIdentity;
const headerFromEmail = resolved?.overrideEmail || sendingIdentity?.email;
const headerFromName = resolved?.overrideName || sendingIdentity?.name || undefined;
const envelopeMailFrom = resolved?.overrideEmail ? sendingIdentity?.email : undefined;
// Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias).
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
separator: useSettingsStore.getState().signatureSeparatorEnabled,
});
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
@@ -1624,14 +1762,15 @@ export default function Home() {
finalBody, finalBody,
undefined, undefined,
undefined, undefined,
primaryIdentity?.id, sendingIdentity?.id,
primaryIdentity?.email, headerFromEmail,
undefined, undefined,
primaryIdentity?.name || undefined, headerFromName,
undefined, undefined,
undefined, undefined,
threading?.inReplyTo, threading?.inReplyTo,
threading?.references, threading?.references,
envelopeMailFrom,
); );
// Mark the original email as answered // Mark the original email as answered
@@ -1660,9 +1799,11 @@ export default function Home() {
// Get current mailbox name for mobile header // Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
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 && isFocusedMailLayout && !hasViewerContent;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching // Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => { const handleEmailSelect = async (email: { id: string }) => {
@@ -1819,7 +1960,8 @@ export default function Home() {
return ( return (
<DragDropProvider> <DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden"> <div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<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" />
@@ -1869,7 +2011,7 @@ export default function Home() {
"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 // Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72", "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", "max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full", !sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow // Desktop: normal flow
@@ -1894,6 +2036,7 @@ export default function Home() {
onCreateFolder={handleCreateFolderFromContextMenu} onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu} onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu} onDeleteFolder={handleDeleteFolderFromContextMenu}
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes} onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => { onCompose={() => {
setComposerMode('compose'); setComposerMode('compose');
@@ -1920,21 +2063,30 @@ export default function Home() {
{/* Main Content Area */} {/* Main Content Area */}
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}> <div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
<div className="flex flex-1 min-h-0"> <div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
{/* Email List - full width on mobile, fixed width on tablet/desktop */} {/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
<div <div
className={cn( className={cn(
"relative flex flex-col h-full bg-background border-r border-border", "relative flex flex-col bg-background",
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
// Mobile: full width, hidden when viewing email // Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0", "max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
isMobile && activeView !== "list" && "max-md:hidden", isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation // Tablet/Desktop: fixed width with collapse animation
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0", !isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
"md:shadow-sm", isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
!isHorizontalMailLayout && "md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out", !isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)} )}
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined} style={
isMobile
? undefined
: isHorizontalMailLayout
? (!shouldHideHorizontalViewerPane ? { height: emailListHeight } : undefined)
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined)
}
> >
{/* Mobile Header for List View */} {/* Mobile Header for List View */}
<MobileHeader <MobileHeader
@@ -2243,7 +2395,7 @@ export default function Home() {
</div> </div>
{/* Email list resize handle (desktop only) */} {/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && ( {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
<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)}
@@ -2251,17 +2403,29 @@ export default function Home() {
onDoubleClick={resetEmailListWidth} onDoubleClick={resetEmailListWidth}
/> />
)} )}
{!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && (
<ResizeHandle
orientation="horizontal"
onResizeStart={() => { dragStartWidth.current = emailListHeight; setIsResizing(true); }}
onResize={(delta) => setEmailListHeight(dragStartWidth.current + delta)}
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
onDoubleClick={resetEmailListHeight}
/>
)}
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */} {/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
<div <div
className={cn( className={cn(
"flex flex-col h-full bg-background flex-1 min-w-0", "flex flex-col bg-background flex-1 min-w-0",
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: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",
shouldHideViewerPane && "md:hidden" shouldHideViewerPane && "md:hidden",
shouldHideHorizontalViewerPane && "md:hidden"
)} )}
> >
{/* Inline Composer - shown in viewer pane */} {/* Inline Composer - shown in viewer pane */}
@@ -2293,7 +2457,13 @@ export default function Home() {
} : 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);
@@ -2448,6 +2618,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 />
+19 -4
View File
@@ -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';
@@ -98,6 +101,7 @@ type Tab =
| 'calendar' | 'calendar'
| 'contacts' | 'contacts'
| 'files' | 'files'
| 'protocol_handlers'
| 'sidebar_apps' | 'sidebar_apps'
| 'about_data' | 'about_data'
| 'themes' | 'themes'
@@ -133,6 +137,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,
@@ -192,6 +197,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.attachment_reminder', 'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity', 'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.default_mail_program', 'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter', 'settings.email_behavior.sub_address_delimiter',
], ],
identities: ['settings.identities'], identities: ['settings.identities'],
@@ -210,6 +216,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: [],
@@ -239,6 +246,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',
@@ -559,6 +567,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,7 +591,7 @@ 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' }, { id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(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
@@ -665,6 +674,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 />}
@@ -677,7 +687,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="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<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"
@@ -707,7 +718,8 @@ export default function SettingsPage() {
} }
return ( return (
<div className="flex flex-col h-dvh bg-background"> <div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<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"
@@ -817,7 +829,9 @@ export default function SettingsPage() {
// Desktop layout // Desktop layout
return ( return (
<div className="flex h-dvh bg-background"> <div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0">
<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
@@ -949,6 +963,7 @@ export default function SettingsPage() {
</> </>
)} )}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
</div> </div>
); );
} }
+6 -2
View File
@@ -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,7 +271,7 @@ 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" />
</Section> </Section>
+2 -1
View File
@@ -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 = [
+4 -14
View File
@@ -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;
@@ -119,18 +121,6 @@ export function DashboardTab() {
</div> </div>
))} ))}
{status && !status.lastLogin && (
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-warning">First login detected</p>
<p className="text-sm text-warning/80 mt-0.5">
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
</p>
</div>
</div>
)}
<SettingsSection title="Server" description="Application and connection details"> <SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application"> <SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span> <span className="text-sm text-foreground">{config?.appName || '-'}</span>
+22 -2
View File
@@ -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 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { 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;
@@ -94,6 +97,13 @@ 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;
}
setInstalling(ext.slug); setInstalling(ext.slug);
setMessage(null); setMessage(null);
@@ -258,6 +268,8 @@ 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);
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 +358,20 @@ 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 ? (
<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"> <span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" /> <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(); }}
+1 -1
View File
@@ -30,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] }, { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
+2 -1
View File
@@ -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() {
+36 -24
View File
@@ -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"
> >
+6 -6
View File
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
<div className="min-h-screen flex items-center justify-center bg-background px-4"> <div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4"> {logoUrl ? (
{logoUrl ? ( <img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" /> ) : (
) : ( <div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-primary" /> <Shield className="w-6 h-6 text-primary" />
)} </div>
</div> )}
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1> <h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p> <p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
</div> </div>
+21 -2
View File
@@ -21,6 +21,9 @@ import {
ChevronUp, ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface PreviewData { interface PreviewData {
extension: { extension: {
@@ -200,6 +203,7 @@ 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);
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
@@ -294,8 +298,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 +317,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" />
+7 -4
View File
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { getConfigDir } from '@/lib/admin/paths';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
'.svg': 'image/svg+xml', '.svg': 'image/svg+xml',
@@ -38,11 +41,11 @@ export async function GET(
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
} }
const filePath = path.join(BRANDING_DIR, safe); const filePath = path.join(getBrandingDir(), safe);
// Ensure resolved path is still within BRANDING_DIR // Ensure resolved path is still within getBrandingDir()
const resolved = path.resolve(filePath); const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) { if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }); return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
} }
+8 -5
View File
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
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 { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises'; import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([ const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml', 'image/svg+xml',
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
}; };
const ext = extMap[file.type] || '.png'; const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`); const safeName = sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(BRANDING_DIR, safeName); const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists // Ensure branding directory exists
if (!existsSync(BRANDING_DIR)) { if (!existsSync(getBrandingDir())) {
await mkdir(BRANDING_DIR, { recursive: true }); await mkdir(getBrandingDir(), { recursive: true });
} }
// Write file to disk // Write file to disk
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico']; const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false; let removed = false;
for (const ext of possibleExts) { for (const ext of possibleExts) {
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`); const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) { if (existsSync(filePath)) {
await unlink(filePath); await unlink(filePath);
removed = true; removed = true;
+25 -2
View File
@@ -2,12 +2,23 @@ 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() {
try { try {
@@ -17,7 +28,19 @@ export async function GET() {
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) {
+6
View File
@@ -278,6 +278,12 @@ export async function POST(request: NextRequest) {
enabled: true, enabled: true,
installedAt: now, 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 }
: {}), : {}),
+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: '/' },
});
}
+25 -11
View File
@@ -4,7 +4,12 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import {
JmapAuthVerificationError,
normalizeJmapServerUrl,
validateProxyAuthHeader,
verifyJmapAuth,
} from '@/lib/auth/verify-jmap-auth';
import { import {
clearStalwartAuthContextInStore, clearStalwartAuthContextInStore,
setStalwartAuthContextInStore, setStalwartAuthContextInStore,
@@ -13,17 +18,20 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; 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';
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');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -70,13 +78,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); // Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie
// we write here is only ever consumed for requests on behalf of this same
// user, so bogus credentials would just yield 401s downstream rather than
// privilege escalation. Untrusted custom endpoints still verify upstream.
const normalizedServerUrl = upstreamTrusted
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
: 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,
@@ -185,8 +199,8 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Delete all session cookies (slots 0-4) // Delete all session cookies across every slot.
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i); clearStalwartAuthContextInStore(cookieStore, i);
} }
+4 -4
View File
@@ -5,16 +5,16 @@ 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 { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { 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
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) { 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 });
} }
@@ -73,7 +73,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');
+13 -4
View File
@@ -1,14 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { JmapAuthVerificationError, 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';
import { recordLogin } from '@/lib/telemetry/login-tracker'; 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';
function getSlot(request: NextRequest, bodySlot: unknown): number { function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
return bodySlot; return bodySlot;
} }
@@ -16,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot; return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? 0 : slot;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -56,7 +57,15 @@ export async function POST(request: NextRequest) {
} }
const slot = getSlot(request, bodySlot); const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); // Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
// just authenticated to JMAP with these credentials, and the cookie we
// write here is only ever consumed for requests on behalf of this same
// user - a bogus auth header would just yield 401s downstream, not
// privilege escalation. For untrusted custom endpoints we still verify
// upstream as before.
const normalizedServerUrl = upstreamTrusted
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
await setStalwartAuthContext(slot, { await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
+5 -4
View File
@@ -4,12 +4,13 @@ import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
@@ -112,9 +113,9 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Revoke and delete all refresh token cookies (slots 0-4) // Revoke and delete all refresh token cookies across every slot.
const cookieStore = await cookies(); const cookieStore = await cookies();
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
const name = refreshTokenCookieName(i); const name = refreshTokenCookieName(i);
const serverCookieName = refreshTokenServerCookieName(i); const serverCookieName = refreshTokenServerCookieName(i);
const token = cookieStore.get(name)?.value; const token = cookieStore.get(name)?.value;
+2 -1
View File
@@ -9,6 +9,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers'; import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Pin the upstream URL to a configured JMAP server. The list of allowed // Pin the upstream URL to a configured JMAP server. The list of allowed
+5 -3
View File
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
@@ -35,8 +36,9 @@ export async function GET() {
oauthOnly, oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''), oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''), oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE), oauthScopes: getOauthScopes(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)), rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled, stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false), devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'), faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
+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',
}); });
+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),
+8 -4
View File
@@ -6,7 +6,8 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env'; import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -49,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
} }
function isEnabled(): boolean { function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)); const flagOn =
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
configManager.get<boolean>('settingsSyncEnabled', false);
return flagOn && hasSessionSecret();
} }
/** Strip trailing slashes so differently-formatted URLs still match. */ /** Strip trailing slashes so differently-formatted URLs still match. */
@@ -59,7 +63,7 @@ function normalizeUrl(url: string): string {
/** /**
* Verify identity against session cookies across all account slots. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies * Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions). * (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found. * Returns true only if a matching cookie is found.
@@ -68,7 +72,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
const cookieStore = await cookies(); const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl); const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie // Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) { if (token) {
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp',
'image/x-icon',
'image/vnd.microsoft.icon',
]);
const VALID_SLOTS = new Set([
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
]);
const EXT_BY_MIME: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
function sanitizeFilename(name: string): string {
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* POST /api/setup/branding - wizard branding upload.
*
* Multipart form fields:
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
*
* Mirrors /api/admin/branding but authenticates via the wizard cookie
* instead of admin session - admin auth doesn't exist yet during bootstrap.
* Files land in the same directory; the public read endpoint at
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
* assets after setup.
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
try {
assertWritable('upload branding asset');
const formData = await request.formData();
const file = formData.get('file');
const slot = formData.get('slot');
if (!(file instanceof File) || typeof slot !== 'string') {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
}
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
{ status: 400 },
);
}
const ext = EXT_BY_MIME[file.type] ?? '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const dir = getBrandingDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
// Remove any existing file for this slot with a different extension so
// the wizard doesn't leave orphan files behind on re-upload.
for (const otherExt of Object.values(EXT_BY_MIME)) {
if (otherExt === ext) continue;
const oldPath = path.join(dir, `${slot}${otherExt}`);
if (existsSync(oldPath)) {
try { await unlink(oldPath); } catch { /* ignore */ }
}
}
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dir, safeName);
await writeFile(filePath, buffer);
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
logger.error('Wizard branding upload failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
/**
* DELETE /api/setup/branding - remove an uploaded asset and clear the
* config override so the slot falls back to the system default.
*
* Body: { slot: string }
*/
export async function DELETE(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
try {
assertWritable('remove branding asset');
const { slot } = (await request.json()) as { slot?: string };
if (!slot || !VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
const dir = getBrandingDir();
for (const ext of Object.values(EXT_BY_MIME)) {
const filePath = path.join(dir, `${slot}${ext}`);
if (existsSync(filePath)) {
try { await unlink(filePath); } catch { /* ignore */ }
}
}
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Wizard branding delete failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'node:fs/promises';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { setInitialAdminPassword } from '@/lib/admin/password';
import { clearSetupToken } from '@/lib/setup/token';
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/finish
*
* Final wizard step. Validates that required config is in place, hashes the
* admin password, marks setup complete, deletes the setup token (which
* invalidates the wizard cookie), and optionally drops a `.config-locked`
* marker so the operator remembers they intended to mount :ro.
*
* Body: { adminPassword: string, lockConfig?: boolean }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { adminPassword?: unknown; lockConfig?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const adminPassword =
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
if (adminPassword.length < 8) {
return NextResponse.json(
{ error: 'Admin password must be at least 8 characters' },
{ status: 400 },
);
}
const lockConfig = body?.lockConfig === true;
// Validate required config is present.
await configManager.ensureLoaded();
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
if (!jmapUrl || typeof jmapUrl !== 'string') {
return NextResponse.json(
{ error: 'JMAP server URL is required (run the Server step first)' },
{ status: 400 },
);
}
try {
// 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
// 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) {
return NextResponse.json(
{ error: 'Failed to write admin credentials' },
{ status: 500 },
);
}
// 2. Persist setupComplete flag. After this, detectSetupState() flips
// to 'configured' and middleware starts 404'ing /setup paths.
await configManager.markSetupComplete();
// 3. Optional advisory lock marker.
if (lockConfig) {
await ensureConfigDir();
await writeFile(
getConfigPath('.config-locked'),
new Date().toISOString(),
'utf-8',
);
}
// 4. Destroy the setup token. Any other browser holding the cookie is
// now unauthenticated.
await clearSetupToken();
await auditLog(
'setup.finish',
{ lockConfig, jmapServerUrl: jmapUrl },
request.headers.get('x-forwarded-for') ?? 'unknown',
);
const response = NextResponse.json({ ok: true, lockConfig });
response.cookies.delete(SETUP_COOKIE);
return response;
} catch (error) {
logger.error('Wizard finish failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { isConfigReadOnly } from '@/lib/admin/paths';
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
export const dynamic = 'force-dynamic';
/**
* GET /api/setup/status - public endpoint that returns the wizard state
* and (if authenticated) the partial config saved by previous steps. The
* wizard polls this on load so a refresh resumes with prior values.
*
* Sensitive values (OAuth client secret, session secret) are NEVER sent
* back to the client - only a `<key>HasValue` boolean. Re-entering them
* after refresh is the price of not exposing them.
*/
export async function GET() {
await configManager.ensureLoaded();
const state = detectSetupState();
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
let partialConfig: Record<string, unknown> | null = null;
if (state === 'bootstrap' && authenticated) {
// Only echo back values the operator has actually saved during the
// wizard (admin overrides). System defaults must not flow back here,
// because the wizard has its own opinionated defaults (e.g. settings
// sync on by default) that we'd otherwise stomp.
const sources = configManager.getAllWithSources();
const safe: Record<string, unknown> = {};
for (const [key, info] of Object.entries(sources)) {
if (info.source !== 'admin') continue;
if (SENSITIVE_CONFIG_KEYS.has(key)) {
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
} else {
safe[key] = info.value;
}
}
partialConfig = safe;
}
return NextResponse.json(
{
state,
authenticated,
readOnly: isConfigReadOnly(),
partialConfig,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* Mapping of wizard-friendly step keys to the config keys they update. Each
* step's PATCH validates against this allowlist so a compromised wizard
* client can't slip in arbitrary config keys.
*/
const STEP_KEYS: Record<string, string[]> = {
server: [
'appName',
'jmapServerUrl',
'stalwartFeaturesEnabled',
'jmapServers',
'jmapServerAutoPickByDomain',
],
auth: [
'oauthEnabled',
'oauthOnly',
'oauthClientId',
'oauthClientSecret',
'oauthIssuerUrl',
],
security: ['sessionSecret', 'settingsSyncEnabled'],
logging: ['logFormat', 'logLevel'],
branding: [
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
],
};
/**
* POST /api/setup/step
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
*
* Persists partial config under the admin override (config.json). Each
* step's allowed keys are restricted by STEP_KEYS so the client can only
* touch what the corresponding screen owns.
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { step?: unknown; values?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const step = typeof body?.step === 'string' ? body.step : '';
const values = body?.values;
const allowedKeys = STEP_KEYS[step];
if (!allowedKeys) {
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
}
if (!values || typeof values !== 'object' || Array.isArray(values)) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
if (!(key in CONFIG_ENV_MAP)) {
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
}
if (key === 'jmapServers') {
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
// before they're persisted. Mirrors the admin config PATCH route.
if (value != null && !Array.isArray(value)) {
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
}
const sanitized = parseJmapServers(value);
const incomingCount = Array.isArray(value) ? value.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json(
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
{ status: 400 },
);
}
updates[key] = sanitized;
continue;
}
updates[key] = value;
}
try {
await configManager.ensureLoaded();
await configManager.setAdminConfig(updates);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Wizard step save failed', {
step,
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
const FETCH_TIMEOUT_MS = 5000;
/**
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
* the check_jmap_server() helper in setup.sh: we hit a few common session
* endpoints and look for capability strings to confirm the URL is actually
* a JMAP server (vs. a generic HTTP 200 page).
*
* Body: { url: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { url?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
if (!raw) {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
}
const base = raw.replace(/\/+$/, '');
for (const endpoint of JMAP_ENDPOINTS) {
const target = base + endpoint;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(target, {
method: 'GET',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) continue;
const text = await res.text();
if (looksLikeJmapSession(text)) {
return NextResponse.json({
status: 'jmap_detected',
endpoint,
httpStatus: res.status,
});
}
} catch {
// Try the next endpoint; we'll fall through to a final reachability
// check below if none match.
}
}
// No JMAP session found. Was the server even reachable?
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(base, {
method: 'HEAD',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
return NextResponse.json({
status: 'reachable_no_jmap',
httpStatus: res.status,
message:
'Server responded but no JMAP session was found at standard paths. ' +
'This is OK if a reverse proxy routes JMAP separately.',
});
} catch (error) {
return NextResponse.json({
status: 'unreachable',
message: error instanceof Error ? error.message : 'Connection failed',
});
}
}
function looksLikeJmapSession(body: string): boolean {
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { verifySetupToken } from '@/lib/setup/token';
import { buildSessionCookieAttributes } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
* startup) for a wizard session cookie. After this, subsequent step calls
* authenticate via the cookie instead of pasting the token every time.
*
* Body: { token: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
let body: { token?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
if (!submitted) {
return NextResponse.json({ error: 'Token required' }, { status: 400 });
}
const ok = await verifySetupToken(submitted);
if (!ok) {
// Don't differentiate between "wrong token" and "no token issued" - the
// operator either has it from the logs or they don't.
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes();
response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite,
secure: attrs.secure,
path: attrs.path,
maxAge: attrs.maxAge,
});
return response;
}
+8 -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,
-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

+11 -3
View File
@@ -1,9 +1,10 @@
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 { configManager } from "@/lib/admin/config-manager";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -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 },
}; };
} }
+21 -1
View File
@@ -2,13 +2,26 @@ import type { MetadataRoute } from "next";
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 function manifest(): ExtendedManifest {
const appName = const appName =
process.env.APP_NAME || process.env.APP_NAME ||
process.env.NEXT_PUBLIC_APP_NAME || process.env.NEXT_PUBLIC_APP_NAME ||
@@ -57,5 +70,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"],
},
}; };
} }
+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")} />;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react';
export default function SetupLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
}
+1801
View File
File diff suppressed because it is too large Load Diff
+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);
+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');
+274 -47
View File
@@ -32,9 +32,10 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form"; import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity"; import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
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,6 +56,10 @@ 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. */
fromOverrideEmail?: string;
fromOverrideName?: string;
fromOverrideEnabled?: boolean;
} }
interface EmailComposerProps { interface EmailComposerProps {
@@ -69,6 +74,7 @@ interface EmailComposerProps {
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
envelopeMailFrom?: 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[];
@@ -111,6 +117,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}${sanitizeEmailHtml(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,
@@ -130,6 +169,25 @@ export function EmailComposer({
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
// below, but uses initialData (or primary) since selectedIdentityId state
// does not exist yet at this point.
const initialCurrentIdentityForSig = initialData?.selectedIdentityId
? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity
: primaryIdentity;
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
? initialCurrentIdentityForSig
: primaryIdentity;
const shouldEmbedSignatureAboveQuote =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote' &&
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
// Initialize with reply/forward data if provided // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
@@ -179,10 +237,19 @@ export function EmailComposer({
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
// When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a
// closing for the reply body. Send-time append is skipped - see
// shouldEmbedSignatureAboveQuote.
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
const signatureBlock = shouldEmbedSignatureAboveQuote
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
: '';
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}\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') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`; return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
} }
return prefix; return prefix;
} }
@@ -194,20 +261,25 @@ 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');
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
embed: shouldEmbedSignatureAboveQuote,
separator: signatureSeparatorEnabled,
});
// 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}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`; 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>`;
} }
if (replyTo.body) { if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>'); const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`; return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`; return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
} }
} }
return prefix; return prefix;
@@ -245,6 +317,9 @@ export function EmailComposer({
const [shakeField, setShakeField] = useState<string | null>(null); const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null); const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? ''); const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false);
@@ -276,24 +351,96 @@ export function EmailComposer({
}); });
const { client } = useAuthStore(); const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity; : primaryIdentity;
// Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature.
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
? currentIdentity
: 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;
if (mode !== 'reply' && mode !== 'replyAll') return; if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, { const resolved = resolveReplyFrom(identities, {
to: replyTo?.to, to: replyTo?.to,
cc: replyTo?.cc, cc: replyTo?.cc,
bcc: replyTo?.bcc, bcc: replyTo?.bcc,
}); });
if (matchedIdentityId) { if (resolved) {
setSelectedIdentityId(matchedIdentityId); setSelectedIdentityId(resolved.identityId);
if (resolved.overrideEmail && !fromOverrideEnabled) {
setFromOverrideEnabled(true);
setFromOverrideEmail(resolved.overrideEmail);
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
}
return; return;
} }
@@ -312,6 +459,7 @@ export function EmailComposer({
} }
}, [ }, [
autoSelectReplyIdentity, autoSelectReplyIdentity,
fromOverrideEnabled,
identities, identities,
initialData?.selectedIdentityId, initialData?.selectedIdentityId,
mode, mode,
@@ -322,10 +470,10 @@ export function EmailComposer({
selectedIdentityId, selectedIdentityId,
]); ]);
const composerSignatureHtml = currentIdentity?.htmlSignature const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>` ? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature : signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(currentIdentity).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>`
: ''; : '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete); const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
@@ -361,8 +509,8 @@ export function EmailComposer({
}, [currentSmimeIdentityId]); }, [currentSmimeIdentityId]);
// Keep a ref to current state for the unmount save // Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }); const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }; stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
// Track initial values for dirty detection (captured once on first render) // Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length }); const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
@@ -755,11 +903,17 @@ export function EmailComposer({
// Get the selected identity or primary identity // Get the selected identity or primary identity
// Generate sub-addressed email if tag is set // Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email const identityFromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
? fromOverrideEmail.trim()
: identityFromEmail;
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
? (fromOverrideName.trim() || undefined)
: (currentIdentity?.name || undefined);
try { try {
const savedDraftId = await client.createDraft( const savedDraftId = await client.createDraft(
@@ -772,7 +926,7 @@ export function EmailComposer({
fromEmail, fromEmail,
draftId || undefined, draftId || undefined,
uploadedAttachments, uploadedAttachments,
currentIdentity?.name || undefined, fromName,
plainTextMode ? undefined : body plainTextMode ? undefined : body
); );
@@ -945,20 +1099,39 @@ export function EmailComposer({
} }
} }
const fromEmail = currentIdentity?.email const identityFromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
// 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
// the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
// default to the override address.
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
const fromName = overrideActive
? (fromOverrideName.trim() || undefined)
: (currentIdentity?.name || undefined);
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
// Body is already HTML from the rich text editor (or plain text in plain text mode). // Body is already HTML from the rich text editor (or plain text in plain text mode).
// When "above quote" mode is configured for replies/forwards, the signature
// was embedded into the body during init (see getInitialBody) so the
// trailing append must be skipped to avoid duplicating it.
const signatureAlreadyInBody =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote';
// 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 (currentIdentity?.htmlSignature) { if (signatureAlreadyInBody) return '';
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`; const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
} }
if (currentIdentity?.textSignature) { if (signatureIdentity?.textSignature) {
return `<br><br>-- <br>${currentIdentity.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 '';
}; };
@@ -969,9 +1142,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
? appendPlainTextSignature(body, currentIdentity) ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity); : (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
@@ -1006,6 +1180,12 @@ export function EmailComposer({
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');
} }
// S/MIME binds to the identity's key; sending from an override address
// would produce a signature whose Subject differs from the visible
// From, which most clients reject or flag. Refuse up front.
if (overrideActive) {
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
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) { if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
@@ -1150,8 +1330,9 @@ export function EmailComposer({
htmlBody: outgoing.htmlBody || undefined, htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail, fromEmail,
fromName: currentIdentity?.name || undefined, fromName,
identityId: outgoing.identityId || currentIdentity?.id, identityId: outgoing.identityId || currentIdentity?.id,
envelopeMailFrom,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
@@ -1179,7 +1360,7 @@ export function EmailComposer({
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({}); setValidationErrors({});
// Clear ref so unmount effect doesn't re-save // Clear ref so unmount effect doesn't re-save
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
} catch (err) { } catch (err) {
debug.error('Failed to send email:', err); debug.error('Failed to send email:', err);
toast.error(t('send_failed')); toast.error(t('send_failed'));
@@ -1190,7 +1371,7 @@ export function EmailComposer({
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
onClose?.(); onClose?.();
}; };
@@ -1200,7 +1381,7 @@ export function EmailComposer({
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
await saveDraft(); await saveDraft();
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
onClose?.(); onClose?.();
}; };
@@ -1212,7 +1393,7 @@ export function EmailComposer({
if (draftId && onDiscardDraft) { if (draftId && onDiscardDraft) {
onDiscardDraft(draftId); onDiscardDraft(draftId);
} }
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
onClose?.(); onClose?.();
}; };
@@ -1296,7 +1477,25 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50"> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1 min-w-0"> <div className="flex-1 flex items-center gap-1 min-w-0">
{identities.length > 1 ? ( {fromOverrideEnabled ? (
<div className="flex-1 flex items-center gap-1 min-w-0">
<Input
value={fromOverrideName}
onChange={(e) => setFromOverrideName(e.target.value)}
placeholder={t('from_override.name_placeholder')}
className="h-7 text-sm w-32 md:w-40 shrink-0"
aria-label={t('from_override.name_label')}
/>
<Input
value={fromOverrideEmail}
onChange={(e) => setFromOverrideEmail(e.target.value)}
placeholder={t('from_override.email_placeholder')}
type="email"
className="h-7 text-sm flex-1 min-w-0 font-mono"
aria-label={t('from_override.email_label')}
/>
</div>
) : identities.length > 1 ? (
<select <select
value={selectedIdentityId || primaryIdentity?.id || ''} value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)} onChange={(e) => setSelectedIdentityId(e.target.value)}
@@ -1328,16 +1527,18 @@ export function EmailComposer({
)} )}
</span> </span>
)} )}
<SubAddressHelper {!fromOverrideEnabled && (
baseEmail={ <SubAddressHelper
(selectedIdentityId baseEmail={
? identities.find(id => id.id === selectedIdentityId)?.email (selectedIdentityId
: primaryIdentity?.email) || '' ? identities.find(id => id.id === selectedIdentityId)?.email
} : primaryIdentity?.email) || ''
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)} }
onSelectTag={setSubAddressTag} recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
/> onSelectTag={setSubAddressTag}
{subAddressTag && ( />
)}
{!fromOverrideEnabled && subAddressTag && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1349,6 +1550,28 @@ export function EmailComposer({
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</Button> </Button>
)} )}
<Button
type="button"
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
size="sm"
onClick={() => {
if (fromOverrideEnabled) {
setFromOverrideEnabled(false);
} else {
setFromOverrideEnabled(true);
if (!fromOverrideEmail && currentIdentity?.email) {
setFromOverrideEmail(currentIdentity.email);
}
if (!fromOverrideName && currentIdentity?.name) {
setFromOverrideName(currentIdentity.name);
}
}
}}
className="h-6 px-2 text-xs shrink-0"
title={t('from_override.toggle_tooltip')}
>
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
</Button>
</div> </div>
</div> </div>
@@ -1495,20 +1718,24 @@ 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>
)} )}
{plainTextMode ? ( {/* Hide the visual signature preview when the signature has already been
getPlainTextSignature(currentIdentity) ? ( embedded into the body above the quote (otherwise it would appear twice). */}
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
: plainTextMode ? (
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(currentIdentity)} {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>
@@ -1566,7 +1793,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
+3
View File
@@ -6,6 +6,7 @@ import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react"; import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
interface EmailHoverActionsProps { interface EmailHoverActionsProps {
email: Email; email: Email;
@@ -76,11 +77,13 @@ export function EmailHoverActions({
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner); const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions"); const t = useTranslations("settings.email_behavior.hover_actions");
const isMobile = useIsMobile();
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName; const hoverBackgroundClassName = backgroundClassName;
if (isMobile) return null;
if (hoverActions.length === 0) return null; if (hoverActions.length === 0) return null;
const handleAction = (e: React.MouseEvent, action: HoverAction) => { const handleAction = (e: React.MouseEvent, action: HoverAction) => {
+8 -7
View File
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback } from "react"; import { useCallback } from "react";
import { formatDate } from "@/lib/utils"; import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -51,7 +51,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
@@ -128,8 +129,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div <div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')} className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
> >
{/* Checkbox - only visible when in selection mode */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
@@ -160,11 +161,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)} )}
{/* Avatar */} {/* Avatar */}
{!isFocusedMailLayout && density !== 'extra-compact' && ( {density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
size="md" size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages} disableImages={hideJunkAvatarImages}
/> />
@@ -295,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/80" : "text-muted-foreground/80"
)}> )}>
{email.preview || "No preview available"} {trimmedPreview || t('no_preview_available')}
</p> </p>
)} )}
</> </>
+1 -1
View File
@@ -109,7 +109,7 @@ export function EmailList({
const estimateSize = useCallback(() => { const estimateSize = useCallback(() => {
if (isFocusedMailLayout) { if (isFocusedMailLayout) {
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density]; return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density];
} }
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base; return (showPreview && density !== 'extra-compact') ? base + 36 : base;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -127,7 +127,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
ref={triggerRef} ref={triggerRef}
onClick={handleOpen} onClick={handleOpen}
className={cn( className={cn(
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors", "text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
className className
)} )}
> >
+64 -2
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import React, { useEffect, useCallback, useState, useRef } from "react"; import React, { useEffect, useCallback, useState, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Paragraph from "@tiptap/extension-paragraph";
import Heading from "@tiptap/extension-heading";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align"; import TextAlign from "@tiptap/extension-text-align";
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
cid?: string; cid?: string;
} }
// Pasted email content (signatures, replies, quoted text) commonly carries
// inline styles on block elements. StarterKit's default Paragraph/Heading
// drop unknown attributes; extend them to round-trip `style` and `class` so
// signature formatting survives the editor.
const styledBlockAttributes = {
style: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.style ? { style: attrs.style } : {},
},
class: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.class ? { class: attrs.class } : {},
},
"data-signature-block": {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs["data-signature-block"]
? { "data-signature-block": attrs["data-signature-block"] }
: {},
},
};
const StyledParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
...styledBlockAttributes,
};
},
});
const StyledHeading = Heading.extend({
addAttributes() {
return {
...this.parent?.(),
...styledBlockAttributes,
};
},
});
interface RichTextEditorProps { interface RichTextEditorProps {
content: string; content: string;
onChange: (html: string) => void; onChange: (html: string) => void;
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
hasError?: boolean; hasError?: boolean;
onEditorReady?: (editor: Editor) => void;
} }
function ToolbarButton({ function ToolbarButton({
@@ -131,15 +179,23 @@ export function RichTextEditor({
placeholder, placeholder,
className, className,
hasError, hasError,
onEditorReady,
}: RichTextEditorProps) { }: RichTextEditorProps) {
const onImageUploadRef = React.useRef(onImageUpload); const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload; onImageUploadRef.current = onImageUpload;
const onEditorReadyRef = React.useRef(onEditorReady);
onEditorReadyRef.current = onEditorReady;
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
heading: { levels: [1, 2] }, heading: false,
paragraph: false,
link: false,
underline: false,
}), }),
StyledParagraph,
StyledHeading.configure({ levels: [1, 2] }),
Underline, Underline,
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
@@ -237,6 +293,12 @@ export function RichTextEditor({
} }
}, [content, editor]); }, [content, editor]);
// Expose the editor instance once it's ready so parents can target
// specific nodes (e.g. swap the embedded signature on identity change).
useEffect(() => {
if (editor) onEditorReadyRef.current?.(editor);
}, [editor]);
const addLink = useCallback(() => { const addLink = useCallback(() => {
if (!editor) return; if (!editor) return;
const previousUrl = editor.getAttributes("link").href; const previousUrl = editor.getAttributes("link").href;
+75 -16
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
@@ -331,8 +331,12 @@ function EmailCard({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value; htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines. // Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
if (hasTextBody && htmlContent) { // in that case there is no real plain-text alternative, so always render the HTML.
const textPartId = email.textBody?.[0]?.partId;
const htmlPartId = email.htmlBody[0].partId;
const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
if (hasDistinctTextBody && htmlContent) {
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
} else { } else {
useHtmlVersion = !!htmlContent; useHtmlVersion = !!htmlContent;
@@ -436,6 +440,49 @@ function EmailCard({
return { html: "", isHtml: false }; return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]); }, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
// emits a <script> tag through a parser quirk.
const iframeRef = useRef<HTMLIFrameElement>(null);
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
const csp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
return `<!DOCTYPE html><html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style></head><body>${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc?.body) return;
const resize = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px';
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(doc.body);
doc.querySelectorAll('a').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, []);
return ( return (
<div className={cn( <div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200", "rounded-lg border border-border overflow-hidden transition-all duration-200",
@@ -479,7 +526,7 @@ function EmailCard({
</div> </div>
{!isExpanded && density !== 'extra-compact' && ( {!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2"> <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"} {email.preview || t('email_viewer.no_preview_available')}
</p> </p>
)} )}
</div> </div>
@@ -530,18 +577,30 @@ function EmailCard({
{/* Email Body */} {/* Email Body */}
<div style={{ padding: 'var(--density-card-p)' }}> <div style={{ padding: 'var(--density-card-p)' }}>
<div {emailContent.isHtml ? (
className={cn( <iframe
"prose prose-sm max-w-none", ref={iframeRef}
!emailAlwaysLightMode && "dark:prose-invert", srcDoc={emailIframeSrcDoc}
"prose-p:my-2 prose-headings:my-3", sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline", title="Email content"
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2", className="w-full border-0 block"
"[&_img]:max-w-full [&_img]:h-auto" style={{ minHeight: '60px' }}
)} onLoad={handleIframeLoad}
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined} />
dangerouslySetInnerHTML={{ __html: emailContent.html }} ) : (
/> <div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
)}
</div> </div>
{/* Attachments */} {/* Attachments */}
+3 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -27,6 +28,7 @@ export function ThreadEmailItem({
onClick, onClick,
onContextMenu, onContextMenu,
}: ThreadEmailItemProps) { }: ThreadEmailItemProps) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
@@ -177,7 +179,7 @@ export function ThreadEmailItem({
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/70" : "text-muted-foreground/70"
)}> )}>
{email.preview || "No preview"} {email.preview || t('no_preview_available')}
</span> </span>
{/* Date */} {/* Date */}
+17 -13
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { formatDate } from "@/lib/utils"; import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -52,6 +52,7 @@ interface SingleEmailItemProps {
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>( const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
@@ -71,7 +72,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Resolve color tags using keyword definitions; unknown tags fall back to gray // Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords); const tagIds = getEmailColorTags(email.keywords);
@@ -148,8 +150,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div <div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')} className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
> >
{/* Checkbox - only visible when in selection mode */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
@@ -178,11 +180,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
)} )}
{!isFocusedMailLayout && density !== 'extra-compact' && ( {density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
size="md" size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages} disableImages={hideJunkAvatarImages}
/> />
@@ -316,7 +318,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/80" : "text-muted-foreground/80"
)}> )}>
{email.preview || "No preview available"} {trimmedPreview || t('no_preview_available')}
</p> </p>
)} )}
</> </>
@@ -359,6 +361,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsSpam, onMarkAsSpam,
}, ref) { }, ref) {
const t = useTranslations('threads'); const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -366,7 +369,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById); const getAccountById = useAccountStore((state) => state.getAccountById);
@@ -506,8 +510,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div <div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')} className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
> >
{/* Checkbox for thread selection - only visible when in selection mode */} {/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
@@ -562,11 +566,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
)} )}
{!isFocusedMailLayout && density !== 'extra-compact' && ( {density !== 'extra-compact' && (
<Avatar <Avatar
name={avatarPerson?.name} name={avatarPerson?.name}
email={avatarPerson?.email} email={avatarPerson?.email}
size="md" size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages} disableImages={hideJunkAvatarImages}
/> />
@@ -722,7 +726,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/80" : "text-muted-foreground/80"
)}> )}>
{latestEmail.preview || "No preview available"} {trimmedPreview || tEmailViewer('no_preview_available')}
</p> </p>
)} )}
</> </>
+19 -5
View File
@@ -182,7 +182,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div> </div>
</div> </div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}> <div className="flex-1 flex items-center justify-center overflow-auto p-4">
{loading && ( {loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground"> <div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" /> <Loader2 className="w-8 h-8 animate-spin" />
@@ -194,13 +194,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)} )}
{!loading && !error && (fileType === "text") && content !== null && ( {!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words"> <pre
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words"
onClick={(e) => e.stopPropagation()}
>
{content} {content}
</pre> </pre>
)} )}
{!loading && !error && fileType === "markdown" && content !== null && ( {!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm"> <div
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm"
onClick={(e) => e.stopPropagation()}
>
<SimpleMarkdown content={content} /> <SimpleMarkdown content={content} />
</div> </div>
)} )}
@@ -211,6 +217,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
alt={name} alt={name}
className="max-w-full max-h-full object-contain rounded-lg bg-background" className="max-w-full max-h-full object-contain rounded-lg bg-background"
draggable={false} draggable={false}
onClick={(e) => e.stopPropagation()}
/> />
)} )}
@@ -220,6 +227,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
sandbox="" sandbox=""
className="w-full max-w-5xl h-full rounded-lg bg-white" className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name} title={name}
onClick={(e) => e.stopPropagation()}
/> />
)} )}
@@ -229,6 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
type="application/pdf" type="application/pdf"
className="w-full max-w-5xl h-full rounded-lg bg-white" className="w-full max-w-5xl h-full rounded-lg bg-white"
aria-label={name} aria-label={name}
onClick={(e) => e.stopPropagation()}
> >
<Button onClick={() => void onDownload()}> <Button onClick={() => void onDownload()}>
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
@@ -238,14 +247,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)} )}
{!loading && !error && fileType === "audio" && objectUrl && ( {!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full"> <div className="bg-background rounded-lg p-8 max-w-lg w-full" onClick={(e) => e.stopPropagation()}>
<p className="text-sm font-medium mb-4 text-center">{name}</p> <p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} /> <audio controls className="w-full" src={objectUrl} />
</div> </div>
)} )}
{!loading && !error && fileType === "video" && objectUrl && ( {!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} /> <video
controls
className="max-w-4xl max-h-full rounded-lg"
src={objectUrl}
onClick={(e) => e.stopPropagation()}
/>
)} )}
</div> </div>
</div> </div>
+1 -1
View File
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label> </label>
<textarea <textarea
id="identity-html-sig" id="identity-html-sig"
maxLength={5000} maxLength={50000}
value={formData.htmlSignature} value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })} onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5} rows={5}
+16 -16
View File
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getMaxAccounts } from "@/lib/account-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
interface AccountSwitcherProps { interface AccountSwitcherProps {
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */ /** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
} }
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) { function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
const initials = getInitials(account.displayName || account.label, account.email || account.username);
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
return ( return (
<div <Avatar
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)} name={account.displayName || account.label}
style={{ backgroundColor: account.avatarColor }} email={account.email || account.username}
title={account.label} size="sm"
> className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
{initials} disableFavicon
</div> fallbackColor={account.avatarColor}
/>
); );
} }
@@ -49,7 +48,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const switchAccount = useAuthStore((s) => s.switchAccount); const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const logoutAll = useAuthStore((s) => s.logoutAll); const logoutAll = useAuthStore((s) => s.logoutAll);
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
if (!buttonRef.current) return; if (!buttonRef.current) return;
@@ -115,9 +113,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId); setDefaultAccount(accountId);
}; };
// Display name for the active account // Show the account's own identity, not the preferred sending identity -
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || ""; // primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || ""; // the actually logged-in account (info@linusrath.de).
const displayName = activeAccount?.displayName || activeAccount?.label || "";
const displayEmail = activeAccount?.email || activeAccount?.username || "";
return ( return (
<> <>
@@ -220,7 +220,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
</div> </div>
{/* Separator + Add Account */} {/* Separator + Add Account */}
{accounts.length < MAX_ACCOUNTS && ( {accounts.length < getMaxAccounts() && (
<div className="border-t border-border"> <div className="border-t border-border">
<button <button
onClick={handleAddAccount} onClick={handleAddAccount}
@@ -20,6 +20,7 @@ import {
Pencil, Pencil,
FolderX, FolderX,
RefreshCw, RefreshCw,
Upload,
} from "lucide-react"; } from "lucide-react";
interface Position { interface Position {
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
onCreateFolder?: () => void; onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void; onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefresh?: () => void; onRefresh?: () => void;
} }
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
onCreateFolder, onCreateFolder,
onRenameFolder, onRenameFolder,
onDeleteFolder, onDeleteFolder,
onImportEmail,
onRefresh, onRefresh,
}: MailboxContextMenuProps) { }: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu"); const t = useTranslations("mailbox_context_menu");
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
const canCreateChild = mailbox.myRights?.mayCreateChild !== false; const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false; const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false; const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const fullPath = getMailboxPath(mailbox, mailboxes); const fullPath = getMailboxPath(mailbox, mailboxes);
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem
icon={Upload}
label={t("import_email")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
/>
<ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
icon={FolderX} icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")} label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
+17 -11
View File
@@ -11,18 +11,18 @@ import { usePathname, Link, useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation"; import { apiFetch } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem { interface NavItem {
id: string; id: string;
@@ -168,7 +168,9 @@ export function NavigationRail({
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore(); const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore(); const client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
const sidebarApps = useSettingsStore((s) => s.sidebarApps); const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
@@ -252,8 +254,8 @@ export function NavigationRail({
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
]; ];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings"); const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
@@ -271,7 +273,7 @@ export function NavigationRail({
if (orientation === "horizontal") { if (orientation === "horizontal") {
return ( return (
<nav <nav
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)} className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]", className)}
role="navigation" role="navigation"
aria-label={t("nav_label")} aria-label={t("nav_label")}
> >
@@ -609,7 +611,6 @@ export function NavigationRail({
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{accounts.map((account) => { {accounts.map((account) => {
const isActive = account.id === activeAccountId; const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return ( return (
<button <button
key={account.id} key={account.id}
@@ -617,15 +618,20 @@ export function NavigationRail({
if (!isActive) switchAccount(account.id); if (!isActive) switchAccount(account.id);
}} }}
className={cn( className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0", "relative w-8 h-8 rounded-full transition-all flex-shrink-0",
isActive isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background" ? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100" : "opacity-70 hover:opacity-100"
)} )}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`} title={`${account.displayName || account.label} (${account.email || account.username})`}
> >
{initials} <Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && ( {isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center"> <span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" /> <Check className="w-2 h-2 text-primary-foreground" />
@@ -634,7 +640,7 @@ export function NavigationRail({
</button> </button>
); );
})} })}
{accounts.length < MAX_ACCOUNTS && ( {accounts.length < getMaxAccounts() && (
<button <button
onClick={() => router.push(`/login?mode=add-account` as never)} onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0" className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
+23 -14
View File
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
onResize: (delta: number) => void; onResize: (delta: number) => void;
onResizeEnd?: () => void; onResizeEnd?: () => void;
onDoubleClick?: () => void; onDoubleClick?: () => void;
orientation?: "vertical" | "horizontal";
className?: string; className?: string;
} }
const KEYBOARD_STEP = 10; const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) { export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className }: ResizeHandleProps) {
const isDragging = useRef(false); const isDragging = useRef(false);
const startX = useRef(0); const startPos = useRef(0);
const isHorizontal = orientation === "horizontal";
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
isDragging.current = true; isDragging.current = true;
startX.current = e.clientX; startPos.current = isHorizontal ? e.clientY : e.clientX;
document.body.style.cursor = "col-resize"; document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
document.body.style.userSelect = "none"; document.body.style.userSelect = "none";
onResizeStart?.(); onResizeStart?.();
}, [onResizeStart]); }, [onResizeStart, isHorizontal]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0; let delta = 0;
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP; if (isHorizontal) {
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP; if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
else return; else if (e.key === "ArrowDown") delta = KEYBOARD_STEP;
else return;
} else {
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
}
e.preventDefault(); e.preventDefault();
onResize(delta); onResize(delta);
onResizeEnd?.(); onResizeEnd?.();
}, [onResize, onResizeEnd]); }, [onResize, onResizeEnd, isHorizontal]);
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return; if (!isDragging.current) return;
const delta = e.clientX - startX.current; const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
onResize(delta); onResize(delta);
}; };
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
}, [onResize, onResizeEnd]); }, [onResize, onResizeEnd, isHorizontal]);
return ( return (
<div <div
role="separator" role="separator"
aria-orientation="vertical" aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize" aria-label="Resize"
tabIndex={0} tabIndex={0}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
className={cn( className={cn(
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group", "flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50", "focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
className className
)} )}
> >
<div className="absolute inset-y-0 -left-1 -right-1" /> <div className={cn("absolute", isHorizontal ? "inset-x-0 -top-1 -bottom-1" : "inset-y-0 -left-1 -right-1")} />
</div> </div>
); );
} }
+23
View File
@@ -20,6 +20,7 @@ import {
Folder, Folder,
FolderOpen, FolderOpen,
User, User,
Users,
Palmtree, Palmtree,
Settings, Settings,
X, X,
@@ -28,6 +29,10 @@ import {
FlaskConical, FlaskConical,
PlayCircle, PlayCircle,
Loader2, Loader2,
AlertTriangle,
NotebookPen,
CalendarClock,
BellOff,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
@@ -66,6 +71,7 @@ interface SidebarProps {
onCreateFolder?: () => void; onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void; onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void; onRefreshMailboxes?: () => void;
className?: string; className?: string;
} }
@@ -87,6 +93,11 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2; if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban; if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
if (role === "archive" || lowerName.includes("archive")) return Archive; if (role === "archive" || lowerName.includes("archive")) return Archive;
if (role === "shared" || lowerName.includes("shared")) return Users;
if (role === "important" || lowerName.includes("important")) return AlertTriangle;
if (role === "memos" || lowerName.includes("memo")) return NotebookPen;
if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock;
if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff;
if (lowerName.includes("star") || lowerName.includes("flag")) return Star; if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
if (hasChildren) { if (hasChildren) {
@@ -103,6 +114,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
trash: "text-muted-foreground", trash: "text-muted-foreground",
junk: "text-red-600/80 dark:text-red-400/80", junk: "text-red-600/80 dark:text-red-400/80",
archive: "text-amber-600/80 dark:text-amber-400/80", archive: "text-amber-600/80 dark:text-amber-400/80",
shared: "text-cyan-600/80 dark:text-cyan-400/80",
important: "text-orange-600/80 dark:text-orange-400/80",
memos: "text-yellow-600/80 dark:text-yellow-400/80",
scheduled: "text-sky-600/80 dark:text-sky-400/80",
snoozed: "text-slate-500/80 dark:text-slate-400/80",
}; };
function resolveRoleKey(role?: string, name?: string): string | undefined { function resolveRoleKey(role?: string, name?: string): string | undefined {
@@ -113,6 +129,11 @@ function resolveRoleKey(role?: string, name?: string): string | undefined {
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash"; if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk"; if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
if (role === "archive" || lowerName.includes("archive")) return "archive"; if (role === "archive" || lowerName.includes("archive")) return "archive";
if (role === "shared" || lowerName.includes("shared")) return "shared";
if (role === "important" || lowerName.includes("important")) return "important";
if (role === "memos" || lowerName.includes("memo")) return "memos";
if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled";
if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed";
return undefined; return undefined;
} }
@@ -636,6 +657,7 @@ export function Sidebar({
onCreateFolder, onCreateFolder,
onRenameFolder, onRenameFolder,
onDeleteFolder, onDeleteFolder,
onImportEmail,
onRefreshMailboxes, onRefreshMailboxes,
className, className,
}: SidebarProps) { }: SidebarProps) {
@@ -1037,6 +1059,7 @@ export function Sidebar({
onCreateFolder={onCreateFolder} onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder} onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder} onDeleteFolder={onDeleteFolder}
onImportEmail={onImportEmail}
onRefresh={onRefreshMailboxes} onRefresh={onRefreshMailboxes}
/> />
</div> </div>
@@ -0,0 +1,16 @@
'use client';
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { useAuthStore } from '@/stores/auth-store';
/**
* Mounts the `app-top-banner` plugin slot with the current session
* username + serverUrl as extraProps. Drop this at the top of every
* authenticated page so plugins like impersonation-notice render
* everywhere, not just on the mail page.
*/
export function AppTopBannerSlot() {
const username = useAuthStore((s) => s.username);
const serverUrl = useAuthStore((s) => s.serverUrl);
return <PluginSlot name="app-top-banner" extraProps={{ username, serverUrl }} />;
}
@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/mailto";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
async function focusExistingClient() {
if (!("serviceWorker" in navigator)) return;
try {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type: "focus-existing-mailto-client" });
} catch {
// Focusing is a progressive enhancement; the composer handoff still works.
}
}
interface MailtoProtocolClientProps {
openingText: string;
}
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
useEffect(() => {
let cancelled = false;
async function handleMailto() {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
const openMode = useSettingsStore.getState().protocolOpenMode;
const parsed = raw ? parseMailto(raw) : null;
if (parsed) {
if (!isFallbackAppTab && openMode === "new-tab") {
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
} else if (!isFallbackAppTab) {
const delivered = await requestOpenMailtoInExistingClient(parsed);
if (cancelled) return;
if (delivered) {
void focusExistingClient();
returnToSourcePage();
return;
}
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
}
savePendingMailto(parsed);
}
window.location.replace(`${getProtocolPathPrefix()}/`);
}
void handleMailto();
return () => {
cancelled = true;
};
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
@@ -0,0 +1,163 @@
"use client";
import { Loader2, X } from "lucide-react";
import { useTranslations } from "next-intl";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import type { AccountEntry } from "@/stores/account-store";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
type ProtocolAccountPickerProps = {
accounts: AccountEntry[];
activeAccountId: string | null;
isSwitching?: boolean;
onSelect: (accountId: string) => void;
onCancel: () => void;
} & (
| { kind: "mailto"; operation?: ParsedMailto }
| { kind: "webcal"; operation?: ParsedWebcal }
);
function getHost(value: string): string {
try {
return new URL(value).hostname;
} catch {
return value;
}
}
export function ProtocolAccountPicker({
kind,
accounts,
activeAccountId,
isSwitching = false,
onSelect,
onCancel,
operation,
}: ProtocolAccountPickerProps) {
const t = useTranslations("protocol_handlers");
const tCommon = useTranslations("common");
const details = operation
? kind === "mailto"
? [
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
]
: [
{ label: t("detail_calendar"), value: operation.suggestedName },
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
]
: [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={t("select_account_title")}
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{details.length > 0 && (
<div className="border-b border-border bg-muted/40 px-5 py-3">
<dl className="space-y-1.5 text-sm">
{details.map((detail) => (
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
</div>
))}
</dl>
</div>
)}
<div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
let host = account.serverUrl;
try {
host = new URL(account.serverUrl).hostname;
} catch {
// Keep the configured value when it is not an absolute URL.
}
return (
<button
key={account.id}
type="button"
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="md"
className="shrink-0"
disableFavicon
fallbackColor={account.avatarColor}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{account.displayName || account.label}
</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{t("active_account")}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
</div>
</button>
);
})}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3">
{isSwitching ? (
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("switching_account")}
</span>
) : (
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
)}
<button
type="button"
onClick={onCancel}
disabled={isSwitching}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{tCommon("cancel")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { getPathPrefix } from "@/lib/browser-navigation";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import {
listenForMailtoRequests,
notifyPendingMailto,
notifyPendingWebcal,
requestOpenMailtoInExistingClient,
savePendingMailto,
savePendingWebcal,
} from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type LaunchParams = { targetURL?: string };
type StandaloneNavigator = Navigator & { standalone?: boolean };
declare global {
interface Window {
launchQueue?: {
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
};
}
}
function getProtocolLaunch(targetURL: string):
| { kind: "mailto"; raw: string }
| { kind: "webcal"; raw: string }
| null {
let url: URL;
try {
url = new URL(targetURL, window.location.origin);
} catch {
return null;
}
if (url.origin !== window.location.origin) return null;
const raw = url.searchParams.get("url");
if (!raw) return null;
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
return null;
}
function isStandaloneDisplayMode() {
return window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
}
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
interface ProtocolLaunchHandlerProviderProps {
children: ReactNode;
}
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
const t = useTranslations("protocol_handlers");
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
return listenForMailtoRequests((pending) => {
savePendingMailto(pending);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
}, () => ({
path: pathname,
standalone: isStandaloneDisplayMode(),
focusNotificationTitle: t("focus_notification_title"),
focusNotificationBody: t("focus_notification_body"),
}));
}, [pathname, router, t]);
useEffect(() => {
if (typeof window === "undefined" || !window.launchQueue) return;
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.targetURL) return;
const launch = getProtocolLaunch(launchParams.targetURL);
if (!launch) return;
if (launch.kind === "mailto") {
const parsed = parseMailto(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("mailto", launch.raw)) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
return;
}
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
if (delivered) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
});
return;
}
const parsed = parseWebcal(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("webcal", launch.raw)) return;
}
savePendingWebcal(parsed);
notifyPendingWebcal();
if (pathname !== "/calendar") router.push("/calendar");
});
}, [pathname, router]);
return children;
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/webcal";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
interface WebcalProtocolClientProps {
openingText: string;
}
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
if (raw) {
const parsed = parseWebcal(raw);
if (parsed) {
if (!isFallbackAppTab
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
&& shouldOpenFallbackAppTab()
&& openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
savePendingWebcal(parsed);
}
}
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
+8 -6
View File
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store'; import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json'; import csMessages from '@/locales/cs/common.json';
import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json'; import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json'; import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json'; import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json'; import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json'; import plMessages from '@/locales/pl/common.json';
@@ -23,13 +24,14 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
cs: csMessages, cs: csMessages,
da: daMessages,
de: deMessages,
en: enMessages, en: enMessages,
es: esMessages,
fr: frMessages, fr: frMessages,
it: itMessages,
ja: jaMessages, ja: jaMessages,
ko: koMessages, ko: koMessages,
es: esMessages,
it: itMessages,
de: deMessages,
lv: lvMessages, lv: lvMessages,
nl: nlMessages, nl: nlMessages,
pl: plMessages, pl: plMessages,
+15
View File
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
initializeTheme(); initializeTheme();
}, [initializeTheme]); }, [initializeTheme]);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const { resolvedTheme, setTheme } = useThemeStore.getState();
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return <>{children}</>; return <>{children}</>;
} }
+8 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const tAdvanced = useTranslations('settings.advanced'); const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour(); const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
{tTour('restart_button')} {tTour('restart_button')}
</Button> </Button>
</SettingItem> </SettingItem>
<SettingItem label={tTour('show_on_new_devices_title')} description={tTour('show_on_new_devices_desc')}>
<ToggleSwitch
checked={showOnboardingOnNewDevices}
onChange={(checked) => updateSetting('showOnboardingOnNewDevices', checked)}
/>
</SettingItem>
</SettingsSection> </SettingsSection>
); );
} }
+22 -35
View File
@@ -1,12 +1,10 @@
"use client"; "use client";
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react'; import { X } from 'lucide-react';
import { getPathPrefix } from '@/lib/browser-navigation';
import { import {
SUPPORTED_SUB_ADDRESS_DELIMITERS, SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter, isSupportedSubAddressDelimiter,
@@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() { export function ComposingSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const { const {
@@ -27,20 +23,11 @@ export function ComposingSettings() {
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
subAddressDelimiter, subAddressDelimiter,
signaturePosition,
signatureSeparatorEnabled,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}> <SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
@@ -50,6 +37,24 @@ export function ComposingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
<Select
value={signaturePosition}
onChange={(value) => updateSetting('signaturePosition', value as 'above_quote' | 'below_quote')}
options={[
{ value: 'above_quote', label: t('signature_position.above_quote') },
{ value: 'below_quote', label: t('signature_position.below_quote') },
]}
/>
</SettingItem>
<SettingItem label={t('signature_separator.label')} description={t('signature_separator.description')}>
<ToggleSwitch
checked={signatureSeparatorEnabled}
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
/>
</SettingItem>
<SettingItem <SettingItem
label={t('sub_address_delimiter.label')} label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })} description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
@@ -148,24 +153,6 @@ export function ComposingSettings() {
</form> </form>
</div> </div>
)} )}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection> </SettingsSection>
); );
} }
+6
View File
@@ -13,6 +13,7 @@ import {
Inbox, Send, FileText, Trash, ShieldAlert, Archive, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users, Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
AlertTriangle, NotebookPen, CalendarClock, BellOff,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils'; import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
trash: Trash, trash: Trash,
junk: ShieldAlert, junk: ShieldAlert,
archive: Archive, archive: Archive,
shared: Users,
important: AlertTriangle,
memos: NotebookPen,
scheduled: CalendarClock,
snoozed: BellOff,
}; };
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [ const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
+39 -8
View File
@@ -13,6 +13,12 @@ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false }, { sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
]; ];
const MAIL_LAYOUT_PREVIEW_ROWS_FOCUS = [
...MAIL_LAYOUT_PREVIEW_ROWS,
{ sender: 'Sam', subject: 'Lunch?', preview: '', selected: false },
{ sender: 'Newsletter', subject: 'Weekly digest', preview: '', selected: false },
];
function MailLayoutPreview({ function MailLayoutPreview({
value, value,
t, t,
@@ -20,8 +26,6 @@ function MailLayoutPreview({
value: MailLayout; value: MailLayout;
t: (key: string) => string; t: (key: string) => string;
}) { }) {
const isSplit = value === 'split';
return ( return (
<div className="mt-3 rounded-xl border border-border bg-background p-3"> <div className="mt-3 rounded-xl border border-border bg-background p-3">
<div> <div>
@@ -33,7 +37,7 @@ function MailLayoutPreview({
<div className="flex h-28"> <div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" /> <div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? ( {value === 'split' && (
<> <>
<div className="w-28 border-r border-border bg-background"> <div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => ( {MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
@@ -56,15 +60,36 @@ function MailLayoutPreview({
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" /> <div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div> </div>
</> </>
) : ( )}
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5"> {value === 'focus' && (
<div className="flex-1 bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS_FOCUS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
</div>
</div>
))}
</div>
)}
{value === 'horizontal' && (
<div className="flex-1 flex flex-col bg-background">
<div className="border-b border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => ( {MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div <div
key={row.subject} key={row.subject}
className={cn( className={cn(
'rounded-md px-2 py-1 text-[10px]', 'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
row.selected ? 'bg-primary/10' : 'bg-muted/20' row.selected && 'bg-primary/10'
)} )}
> >
<div className="truncate text-foreground"> <div className="truncate text-foreground">
@@ -74,6 +99,11 @@ function MailLayoutPreview({
</div> </div>
))} ))}
</div> </div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2 w-20 rounded bg-foreground/10" />
<div className="mt-1.5 h-1.5 w-full rounded bg-foreground/10" />
<div className="mt-1 h-1.5 w-5/6 rounded bg-foreground/10" />
</div>
</div> </div>
)} )}
</div> </div>
@@ -100,6 +130,7 @@ export function LayoutSettings() {
options={[ options={[
{ value: 'split', label: tEmail('mail_layout.split') }, { value: 'split', label: tEmail('mail_layout.split') },
{ value: 'focus', label: tEmail('mail_layout.focus') }, { value: 'focus', label: tEmail('mail_layout.focus') },
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
]} ]}
/> />
<MailLayoutPreview value={mailLayout} t={tEmail} /> <MailLayoutPreview value={mailLayout} t={tEmail} />
@@ -52,11 +52,14 @@ export function NotificationSettings() {
useEffect(() => { useEffect(() => {
if (!supported) return; if (!supported) return;
if (!client) return;
const accountId = client.getAccountId();
if (!accountId) return;
void (async () => { void (async () => {
const enabled = await isWebPushEnabled(); const enabled = await isWebPushEnabled(accountId);
if (enabled) setPushStatus({ kind: 'enabled' }); setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
})(); })();
}, [supported]); }, [supported, client]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, ''); const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay); const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { getPathPrefix } from "@/lib/browser-navigation";
import { useSettingsStore } from "@/stores/settings-store";
import type { ProtocolOpenMode } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection, SettingItem, Select } from "./settings-section";
type Protocol = "mailto" | "webcal";
function canRegisterProtocolHandler(): boolean {
return typeof navigator !== "undefined"
&& "registerProtocolHandler" in navigator
&& typeof window !== "undefined"
&& window.isSecureContext;
}
function getProtocolHandlerUrl(protocol: Protocol) {
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
}
function registerProtocolHandler(protocol: Protocol) {
navigator.registerProtocolHandler(
protocol,
getProtocolHandlerUrl(protocol),
);
}
interface ProtocolHandlerSettingsProps {
supportsCalendar: boolean;
}
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
const t = useTranslations("protocol_handlers");
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const [supported, setSupported] = useState(false);
useEffect(() => {
setSupported(canRegisterProtocolHandler());
}, []);
const handleOpenModeChange = async (value: string) => {
const openMode = value as ProtocolOpenMode;
if (openMode === "active-session"
&& typeof window !== "undefined"
&& "Notification" in window
&& Notification.permission === "default") {
await Notification.requestPermission();
}
updateSetting("protocolOpenMode", openMode);
};
const handleRegister = (protocol: Protocol) => {
try {
registerProtocolHandler(protocol);
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
} catch {
toast.error(t("registration_failed"));
}
};
const renderRegistrationControl = (protocol: Protocol) => {
return (
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
</Button>
);
};
return (
<SettingsSection title={t("title")} description={t("description")}>
{!supported && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("unsupported")}
</div>
)}
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
{renderRegistrationControl("mailto")}
</SettingItem>
{supportsCalendar && (
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
{renderRegistrationControl("webcal")}
</SettingItem>
)}
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
<Select
value={protocolOpenMode}
onChange={handleOpenModeChange}
options={[
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
{ value: "active-session", label: t("protocol_open_mode_active_session") },
]}
/>
</SettingItem>
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
</SettingsSection>
);
}
+29 -4
View File
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store"; import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { getTourSteps, type TourStep } from "./tour-steps"; import { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay"; import { TourOverlay } from "./tour-overlay";
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
const { isDemoMode } = useAuthStore(); const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore(); const { supportsWebDAV } = useWebDAVStore();
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [isActive, setIsActive] = useState(false); const [isActive, setIsActive] = useState(false);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false }); const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => { useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
// setting isn't yet, mirror it into synced state.
try { try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true"); const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
if (legacy && !tourCompleted) {
updateSetting("tourCompleted", true);
}
} catch { /* */ } } catch { /* */ }
}, []); }, [tourCompleted, updateSetting]);
useEffect(() => {
if (!tourCompleted) {
setHasCompletedTour(false);
return;
}
if (showOnboardingOnNewDevices) {
try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
return;
} catch { /* */ }
}
setHasCompletedTour(true);
}, [tourCompleted, showOnboardingOnNewDevices]);
const startTour = useCallback(() => { const startTour = useCallback(() => {
let resumeStep = 0; let resumeStep = 0;
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const completeTour = useCallback(() => { const completeTour = useCallback(() => {
setIsActive(false); setIsActive(false);
setHasCompletedTour(true); setHasCompletedTour(true);
updateSetting("tourCompleted", true);
try { try {
localStorage.setItem(TOUR_COMPLETED_KEY, "true"); localStorage.setItem(TOUR_COMPLETED_KEY, "true");
localStorage.removeItem(TOUR_CURRENT_STEP_KEY); localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ } } catch { /* */ }
}, []); }, [updateSetting]);
const nextStep = useCallback(() => { const nextStep = useCallback(() => {
if (currentStep >= steps.length - 1) { if (currentStep >= steps.length - 1) {
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const resetTourCompletion = useCallback(() => { const resetTourCompletion = useCallback(() => {
setHasCompletedTour(false); setHasCompletedTour(false);
updateSetting("tourCompleted", false);
try { try {
localStorage.removeItem(TOUR_COMPLETED_KEY); localStorage.removeItem(TOUR_COMPLETED_KEY);
localStorage.removeItem(TOUR_CURRENT_STEP_KEY); localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ } } catch { /* */ }
}, []); }, [updateSetting]);
const value: TourContextValue = { const value: TourContextValue = {
isActive, isActive,
+15 -4
View File
@@ -142,9 +142,13 @@ interface AvatarProps {
className?: string; className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */ /** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean; disableImages?: boolean;
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
disableFavicon?: boolean;
/** Background color used when no image source resolves. Overrides the hash-based default. */
fallbackColor?: string;
} }
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null); const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -189,10 +193,17 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const getInitials = () => { const getInitials = () => {
if (name) { if (name) {
const parts = name.trim().split(/\s+/); const parts = name
.trim()
.split(/\s+/)
.map((p) => p.replace(/^[^\p{L}\p{N}]+/u, ""))
.filter((p) => p.length > 0);
if (parts.length >= 2) { if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
} }
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return name.slice(0, 2).toUpperCase(); return name.slice(0, 2).toUpperCase();
} }
if (email) { if (email) {
@@ -219,7 +230,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null; const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
const showFavicon = const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
@@ -250,7 +261,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
sizeClasses[size], sizeClasses[size],
className className
)} )}
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }} style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
title={name || email} title={name || email}
> >
{imgSrc ? ( {imgSrc ? (
+15 -4
View File
@@ -201,15 +201,27 @@ export function FlagCS(props: FlagProps) {
); );
} }
/** Denmark Red with a white Nordic cross */
export function FlagDK(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 28" width={W} height={H} className={flagClass} {...props}>
<path fill="#C8102E" d="M0,0H37V28H0Z" />
<path stroke="#fff" strokeWidth="4" d="M0,14h37M14,0v28" />
</svg>
);
}
/** Map locale codes to flag components */ /** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = { export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
cs: FlagCS,
da: FlagDK,
de: FlagDE,
en: FlagGB, en: FlagGB,
es: FlagES,
fr: FlagFR, fr: FlagFR,
it: FlagIT,
ja: FlagJP, ja: FlagJP,
ko: FlagKR, ko: FlagKR,
es: FlagES,
it: FlagIT,
de: FlagDE,
lv: FlagLV, lv: FlagLV,
nl: FlagNL, nl: FlagNL,
pl: FlagPL, pl: FlagPL,
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
tr: FlagTR, tr: FlagTR,
uk: FlagUA, uk: FlagUA,
zh: FlagCN, zh: FlagCN,
cs: FlagCS,
}; };
+8 -7
View File
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
const languages = [ const languages = [
{ value: 'cs', label: 'Česky' }, { value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' }, { value: 'da', label: 'Dansk' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' }, { value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'lv', label: 'Latviešu' }, { value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' }, { value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' }, { value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' }, { value: 'uk', label: 'Українська' },
{ value: 'ko', label: '한국어' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', label: '简体中文' }, { value: 'zh', label: '简体中文' },
]; ];
+29 -4
View File
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider"; import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
const ONBOARDING_KEY = "onboarding_completed"; const ONBOARDING_KEY = "onboarding_completed";
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
const t = useTranslations("welcome"); const t = useTranslations("welcome");
const router = useRouter(); const router = useRouter();
const { startTour } = useTour(); const { startTour } = useTour();
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
useEffect(() => { useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
// setting isn't yet, mirror it into synced state so the user isn't shown
// the banner again on this device after the upgrade.
try { try {
if (!localStorage.getItem(ONBOARDING_KEY)) { const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
setVisible(true); if (legacy && !onboardingCompleted) {
updateSetting("onboardingCompleted", true);
} }
} catch { /* localStorage unavailable */ } } catch { /* localStorage unavailable */ }
}, []); }, [onboardingCompleted, updateSetting]);
useEffect(() => {
if (!onboardingCompleted) {
setVisible(true);
return;
}
if (showOnboardingOnNewDevices) {
try {
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
setVisible(true);
return;
}
} catch { /* localStorage unavailable */ }
}
setVisible(false);
}, [onboardingCompleted, showOnboardingOnNewDevices]);
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
setDismissed(true); setDismissed(true);
updateSetting("onboardingCompleted", true);
try { try {
localStorage.setItem(ONBOARDING_KEY, "true"); localStorage.setItem(ONBOARDING_KEY, "true");
} catch { /* localStorage unavailable */ } } catch { /* localStorage unavailable */ }
}, []); }, [updateSetting]);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
+7 -1
View File
@@ -11,8 +11,13 @@ services:
volumes: volumes:
# Encrypted user settings (SETTINGS_DATA_DIR). # Encrypted user settings (SETTINGS_DATA_DIR).
- bulwark-settings:/app/data/settings - bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR). # Admin configuration: config.json, policy.json, admin.json (passwordHash),
# plugins, themes, branding uploads (ADMIN_CONFIG_DIR). Can be mounted
# read-only after running the setup wizard - append `:ro` to lock it.
- bulwark-admin:/app/data/admin - bulwark-admin:/app/data/admin
# Admin runtime state: admin-state.json (login timestamps), audit.log,
# setup token (ADMIN_STATE_DIR). Always read-write.
- bulwark-admin-state:/app/data/admin-state
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR). # Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades. # Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry - bulwark-telemetry:/app/data/telemetry
@@ -35,4 +40,5 @@ services:
volumes: volumes:
bulwark-settings: bulwark-settings:
bulwark-admin: bulwark-admin:
bulwark-admin-state:
bulwark-telemetry: bulwark-telemetry:
+2
View File
@@ -45,6 +45,7 @@ export default [
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"no-unused-vars": "off", "no-unused-vars": "off",
"no-undef": "off",
}, },
settings: { settings: {
react: { react: {
@@ -77,6 +78,7 @@ export default [
"*.config.mjs", "*.config.mjs",
"e2e/**", "e2e/**",
"local-data/**/*.mjs", "local-data/**/*.mjs",
"benchmark/**",
], ],
}, },
]; ];
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useCallback, useEffect, useRef, DragEvent } from "react";
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
// drop to materialize a real file. Firefox and Safari ignore it, so we only
// enable drag-out where it actually works.
export function isDragOutSupported(): boolean {
if (typeof navigator === "undefined") return false;
const uaData = (navigator as { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
if (uaData?.brands?.length) {
return uaData.brands.some((b) => /Chromium|Google Chrome|Microsoft Edge|Brave|Opera/i.test(b.brand));
}
const ua = navigator.userAgent || "";
if (/Firefox|FxiOS/.test(ua)) return false;
if (/^((?!chrome|android).)*safari/i.test(ua)) return false;
return /Chrome|Chromium|Edg\//.test(ua);
}
export interface AttachmentDragSource {
name: string;
type: string;
getBlobUrl: () => Promise<string | null>;
}
export interface UseAttachmentDragResult {
draggable: boolean;
onPointerEnter: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
}
const NOOP_HANDLERS: UseAttachmentDragResult = {
draggable: false,
onPointerEnter: () => {},
onDragStart: () => {},
onDragEnd: () => {},
};
export function useAttachmentDrag(
source: AttachmentDragSource,
enabled: boolean,
): UseAttachmentDragResult {
const urlRef = useRef<string | null>(null);
const ownedRef = useRef<boolean>(false);
const inFlightRef = useRef<Promise<string | null> | null>(null);
useEffect(() => {
return () => {
if (urlRef.current && ownedRef.current) {
URL.revokeObjectURL(urlRef.current);
}
urlRef.current = null;
ownedRef.current = false;
inFlightRef.current = null;
};
}, []);
const prefetch = useCallback(() => {
if (!enabled) return;
if (urlRef.current || inFlightRef.current) return;
inFlightRef.current = source
.getBlobUrl()
.then((url) => {
if (url && !urlRef.current) {
urlRef.current = url;
// Mark as owned so we revoke on unmount. Callers that hand back a
// shared URL (e.g. a cached thumbnail blob URL) can return the same
// string each time - we still revoke once on unmount.
ownedRef.current = true;
}
return url;
})
.catch(() => null)
.finally(() => {
inFlightRef.current = null;
});
}, [enabled, source]);
const handleDragStart = useCallback(
(e: DragEvent<HTMLDivElement>) => {
const url = urlRef.current;
const name = source.name || "download";
const type = source.type || "application/octet-stream";
if (!url) {
// Blob isn't materialized yet. Kick off the fetch so the next attempt
// works, but cancel this drag so the user doesn't get a silent failure
// where the OS receives no file.
prefetch();
e.preventDefault();
return;
}
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
// drop and writes a real file at the destination.
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
[source.name, source.type, prefetch],
);
const handleDragEnd = useCallback(() => {
// Keep the blob URL around briefly - Chromium asynchronously fetches the
// blob: URL after dragend fires, so revoking immediately races the OS.
if (urlRef.current && ownedRef.current) {
const url = urlRef.current;
urlRef.current = null;
ownedRef.current = false;
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
}, []);
if (!enabled) return NOOP_HANDLERS;
return {
draggable: true,
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
};
}
+4
View File
@@ -12,6 +12,7 @@ interface ConfigData {
oauthOnly: boolean; oauthOnly: boolean;
oauthClientId: string; oauthClientId: string;
oauthIssuerUrl: string; oauthIssuerUrl: string;
oauthScopes: string;
rememberMeEnabled: boolean; rememberMeEnabled: boolean;
settingsSyncEnabled: boolean; settingsSyncEnabled: boolean;
stalwartFeaturesEnabled: boolean; stalwartFeaturesEnabled: boolean;
@@ -90,6 +91,7 @@ export function useConfig(): AppConfig {
oauthOnly: configCache?.oauthOnly || false, oauthOnly: configCache?.oauthOnly || false,
oauthClientId: configCache?.oauthClientId || '', oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '', oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
oauthScopes: configCache?.oauthScopes || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false, rememberMeEnabled: configCache?.rememberMeEnabled || false,
settingsSyncEnabled: configCache?.settingsSyncEnabled || false, settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true, stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
@@ -124,6 +126,7 @@ export function useConfig(): AppConfig {
oauthOnly: configCache.oauthOnly, oauthOnly: configCache.oauthOnly,
oauthClientId: configCache.oauthClientId, oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl, oauthIssuerUrl: configCache.oauthIssuerUrl,
oauthScopes: configCache.oauthScopes,
rememberMeEnabled: configCache.rememberMeEnabled, rememberMeEnabled: configCache.rememberMeEnabled,
settingsSyncEnabled: configCache.settingsSyncEnabled, settingsSyncEnabled: configCache.settingsSyncEnabled,
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled, stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
@@ -159,6 +162,7 @@ export function useConfig(): AppConfig {
oauthOnly: data.oauthOnly, oauthOnly: data.oauthOnly,
oauthClientId: data.oauthClientId, oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl, oauthIssuerUrl: data.oauthIssuerUrl,
oauthScopes: data.oauthScopes,
rememberMeEnabled: data.rememberMeEnabled, rememberMeEnabled: data.rememberMeEnabled,
settingsSyncEnabled: data.settingsSyncEnabled, settingsSyncEnabled: data.settingsSyncEnabled,
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled, stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
+5 -2
View File
@@ -14,8 +14,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'cs': case 'cs':
messages = (await import('../locales/cs/common.json')).default; messages = (await import('../locales/cs/common.json')).default;
break; break;
case 'fr': case 'da':
messages = (await import('../locales/fr/common.json')).default; messages = (await import('../locales/da/common.json')).default;
break; break;
case 'de': case 'de':
messages = (await import('../locales/de/common.json')).default; messages = (await import('../locales/de/common.json')).default;
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'es': case 'es':
messages = (await import('../locales/es/common.json')).default; messages = (await import('../locales/es/common.json')).default;
break; break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
case 'it': case 'it':
messages = (await import('../locales/it/common.json')).default; messages = (await import('../locales/it/common.json')).default;
break; break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed'; | 'as-needed';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'], locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
defaultLocale: 'en', defaultLocale: 'en',
localePrefix localePrefix
}); });
+31 -3
View File
@@ -1,6 +1,9 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager"; import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password"; import { initAdminPassword } from "./lib/admin/password";
import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
import { detectSetupState } from "./lib/setup/state";
import { ensureSetupToken } from "./lib/setup/token";
const pkg = JSON.parse( const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8") readFileSync(`${process.cwd()}/package.json`, "utf-8")
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
const current: string = pkg.version ?? "0.0.0"; const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`); console.info(`Bulwark Webmail v${current}`);
// Initialize admin config and password bootstrap // Initialize admin config and password bootstrap. Migration runs first so
configManager.load() // existing v1 layouts are split before anything reads admin.json.
migrateLegacyAdminLayout()
.then(() => configManager.load())
.then(() => initAdminPassword()) .then(() => initAdminPassword())
.then(() => { .then(async () => {
console.info("Admin dashboard initialized"); console.info("Admin dashboard initialized");
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
// setupComplete in config.json), generate/refresh the setup token and
// print it to the logs so the operator can complete the web wizard
// without execing into the container.
if (detectSetupState() === "bootstrap") {
try {
const token = await ensureSetupToken();
const port = process.env.PORT || "3000";
console.info("");
console.info("==============================================================");
console.info(" SETUP REQUIRED");
console.info(` Token: ${token}`);
console.info(` Open: http://<host>:${port}/setup?token=${token}`);
console.info(" Token expires in 1 hour. Restart the container to reissue.");
console.info("==============================================================");
console.info("");
} catch (err) {
console.warn(
"Failed to issue setup token:",
err instanceof Error ? err.message : err,
);
}
}
}) })
.then(async () => { .then(async () => {
// Anonymous telemetry - on by default. Admins can disable via the // Anonymous telemetry - on by default. Admins can disable via the
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
expect(plainTextToComposerBody("")).toBe("");
});
it("escapes HTML before building composer paragraphs", () => {
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
"<p>&lt;script&gt;alert(&#39;x&#39;) &amp; &quot;q&quot;&lt;/script&gt;</p>"
);
});
it("normalizes line endings and preserves single line breaks", () => {
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
"<p>line1<br>line2<br>line3</p>"
);
});
it("splits paragraphs on blank lines", () => {
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
"<p>first</p><p>second<br>third</p>"
);
});
});
+65 -8
View File
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe'); expect(clean).toContain('John Doe');
}); });
it('should remove images from signatures', () => { it('should allow img with https src', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">'; const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
const clean = sanitizeSignatureHtml(signature); const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
expect(clean).toContain('alt="Logo"');
expect(clean).toContain('width="120"');
expect(clean).toContain('height="40"');
});
it('should allow img with data:image/png;base64 src', () => {
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
const signature = `<img src="${dataUri}" alt="Logo">`;
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('data:image/png;base64,');
});
it('should allow img with data:image/jpeg, gif, webp', () => {
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
for (const src of cases) {
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
expect(clean).toContain('<img');
expect(clean).toContain(src);
}
});
it('should strip img with http: src (https only)', () => {
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('http://insecure.example.com');
expect(clean).not.toContain('<img'); expect(clean).not.toContain('<img');
expect(clean).toContain('John'); });
it('should strip img with javascript: src', () => {
const signature = '<img src="javascript:alert(1)" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('javascript:');
expect(clean).not.toContain('<img');
});
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:image/svg');
expect(clean).not.toContain('<img');
});
it('should strip img with non-image data: URI', () => {
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:text/html');
expect(clean).not.toContain('<img');
});
it('should strip event handlers on img', () => {
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('onerror');
expect(clean).not.toContain('onload');
expect(clean).toContain('https://cdn.example.com/logo.png');
}); });
it('should remove video and audio tags', () => { it('should remove video and audio tags', () => {
@@ -113,16 +169,17 @@ describe('email-sanitization', () => {
}); });
it('should be stricter than email sanitization', () => { it('should be stricter than email sanitization', () => {
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>'; const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video>';
const emailClean = sanitizeEmailHtml(html); const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html); const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table // Email allows table
expect(emailClean).toContain('<img');
expect(emailClean).toContain('<table>'); expect(emailClean).toContain('<table>');
// Signature blocks img but may allow some tables (verify in implementation) // Signature blocks table and video
expect(signatureClean).not.toContain('<img'); expect(signatureClean).not.toContain('<table');
expect(signatureClean).not.toContain('<video');
expect(signatureClean).toContain('Text');
}); });
}); });

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