Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c5aec9ca4 | ||
|
|
375220298d | ||
|
|
452976ed95 | ||
|
|
243a2adfbf | ||
|
|
1ba4a13353 | ||
|
|
5de12dfb79 | ||
|
|
689d646c57 | ||
|
|
49cd7f8130 | ||
|
|
4545e212f4 | ||
|
|
b1f4f6eae0 | ||
|
|
9a431a873b | ||
|
|
bb7e1c4538 | ||
|
|
356abcfc2d | ||
|
|
3099b4801e | ||
|
|
fc641e94ac | ||
|
|
0e758409ee | ||
|
|
8c93941d8d | ||
|
|
4221c9a50f | ||
|
|
3a559479bd | ||
|
|
482493a10d | ||
|
|
0e1036eb49 | ||
|
|
349406723c | ||
|
|
997bedc91b | ||
|
|
307e6d5d34 | ||
|
|
ca1108f455 | ||
|
|
0ff88f36ed | ||
|
|
285b4e349c | ||
|
|
2b4ebb1fbb | ||
|
|
a829c2818f | ||
|
|
c54cf73c3a | ||
|
|
c45ef86924 | ||
|
|
f39366b470 | ||
|
|
b725000f4d | ||
|
|
105194a8b9 | ||
|
|
8dbb538c98 | ||
|
|
e435356c53 | ||
|
|
6f9982540c | ||
|
|
d0d6632b24 | ||
|
|
4b7009dfc2 | ||
|
|
55a408e810 | ||
|
|
d5dddba6df | ||
|
|
d1a0667c79 | ||
|
|
e700e4fd04 | ||
|
|
cf993c1036 | ||
|
|
5fdf226ebe | ||
|
|
fae15f073e | ||
|
|
c646c87030 | ||
|
|
b4a76bc4d1 | ||
|
|
dfe886636b | ||
|
|
f499e87d2a | ||
|
|
32fe871b70 | ||
|
|
aab19379e2 | ||
|
|
b46a1a69e8 | ||
|
|
ea424cad7e | ||
|
|
3f444a8912 | ||
|
|
8b0e2052cf | ||
|
|
c99934a92c | ||
|
|
ce2731cd9d | ||
|
|
f9f8af2f11 | ||
|
|
d8e2a10806 | ||
|
|
869ee07ebc | ||
|
|
2ad2bb1e09 | ||
|
|
23bc31c661 | ||
|
|
a2f76037a1 | ||
|
|
9571f2e185 | ||
|
|
887b9c728c | ||
|
|
8c21f462c2 | ||
|
|
5f3d2d3e4a | ||
|
|
4bce80b8ba | ||
|
|
1d09f5a623 | ||
|
|
2c513129f2 | ||
|
|
b3dc2e32b8 | ||
|
|
b0640c9ecc | ||
|
|
2d7e24b513 | ||
|
|
5b30bacf10 | ||
|
|
fe937403f3 | ||
|
|
876ea370e4 | ||
|
|
1dcdeeae86 | ||
|
|
01302a775c | ||
|
|
76d78ae756 | ||
|
|
51745ea03d | ||
|
|
c44a9ce6e0 | ||
|
|
7fa65796f0 | ||
|
|
d09df7e8a3 | ||
|
|
090399a308 | ||
|
|
c31a58af1a | ||
|
|
65aabb943c | ||
|
|
9c8739c4bb | ||
|
|
48f72be209 | ||
|
|
92fb0c63e9 | ||
|
|
55596556ef | ||
|
|
abd63d124f | ||
|
|
562080b7a3 | ||
|
|
41c9f4926c | ||
|
|
e7e78072d4 | ||
|
|
cd363b4840 | ||
|
|
3a350c14a6 | ||
|
|
b0765bf085 | ||
|
|
9225ba0790 | ||
|
|
3b36738192 | ||
|
|
3edd35ab57 | ||
|
|
a86a96e390 | ||
|
|
5f464d4ee2 |
+21
-4
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Admin Dashboard Data
|
||||
# =============================================================================
|
||||
|
||||
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||
# volume there (see docker-compose.yml).
|
||||
# Admin data is split across two directories so the config volume can be
|
||||
# mounted read-only after the setup wizard completes (see issue #226).
|
||||
#
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+149
@@ -1,5 +1,154 @@
|
||||
# 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)
|
||||
|
||||
### Features
|
||||
|
||||
+26
-34
@@ -10,14 +10,17 @@
|
||||
|
||||
# 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
|
||||
**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.
|
||||
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.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
## Join the Community
|
||||
|
||||
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
|
||||
|
||||
- **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)
|
||||
|
||||
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## 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
|
||||
const t = useTranslations("namespace");
|
||||
return <div>{t("key")}</div>;
|
||||
```
|
||||
|
||||
2. **Translation file locations**:
|
||||
- English: `/locales/en/common.json`
|
||||
- French: `/locales/fr/common.json`
|
||||
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.
|
||||
|
||||
3. **Namespace organization**:
|
||||
- `login.*` - Login page strings
|
||||
- `sidebar.*` - Sidebar navigation
|
||||
- `email_list.*` - Email list component
|
||||
- `email_viewer.*` - Email viewer component
|
||||
- `email_composer.*` - Email composer
|
||||
- `common.*` - Shared strings
|
||||
- `notifications.*` - Toast/alert messages
|
||||
- `settings.*` - Settings page
|
||||
- `login.*` - login page
|
||||
- `sidebar.*` - sidebar navigation
|
||||
- `email_list.*` - email list
|
||||
- `email_viewer.*` - email viewer
|
||||
- `email_composer.*` - composer
|
||||
- `settings.*` - settings page
|
||||
- `notifications.*` - toasts and alerts
|
||||
- `common.*` - shared strings
|
||||
|
||||
4. **Adding new strings**:
|
||||
- Add to **both** English and French translation files
|
||||
- Use descriptive, hierarchical keys
|
||||
- Keep translations consistent in tone
|
||||
4. **Locale-aware navigation**:
|
||||
|
||||
5. **Locale-aware navigation**:
|
||||
```tsx
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
@@ -203,16 +200,11 @@ webmail/
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
- **Block external content** by default - privacy is the point
|
||||
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
|
||||
- Open an issue for discussion
|
||||
- Check existing issues and pull requests
|
||||
|
||||
Thank you for helping improve Bulwark Webmail!
|
||||
Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
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
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
+24
-13
@@ -2,19 +2,23 @@
|
||||
|
||||
## 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
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Draft auto-save with identity preservation
|
||||
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes
|
||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- Configurable signature position (above or below quoted text) per identity
|
||||
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
|
||||
- `.eml` file import via folder right-click menu
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
@@ -79,7 +83,7 @@
|
||||
|
||||
## 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
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
@@ -94,31 +98,38 @@
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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
|
||||
- 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
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
## Admin & Extensibility
|
||||
|
||||
- Stalwart admin dashboard with dedicated policy sections
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||
- Automatic update check with server-side logging of new releases
|
||||
- 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 and a non-dismissible update notice
|
||||
- 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
|
||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||
- Demo mode with fixture data – no mail server required
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](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
|
||||
|
||||
<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
|
||||
- **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)**.
|
||||
|
||||
@@ -74,28 +97,25 @@ Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e JMAP_SERVER_URL=https://mail.example.com \
|
||||
ghcr.io/bulwarkmail/webmail:latest
|
||||
docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
|
||||
```
|
||||
|
||||
Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
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
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
npm run build && npm start
|
||||
# Then open http://localhost:3000 to run the setup wizard
|
||||
```
|
||||
|
||||
### Development
|
||||
@@ -108,13 +128,13 @@ npm run lint
|
||||
|
||||
## 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`:
|
||||
|
||||
```env
|
||||
# Required
|
||||
# Optional – overrides whatever the wizard writes
|
||||
JMAP_SERVER_URL=https://mail.example.com
|
||||
|
||||
# Optional
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
@@ -218,6 +238,19 @@ LOG_LEVEL=info # error | warn | info | debug
|
||||
|
||||
</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>
|
||||
<summary>Subpath / reverse proxy mount</summary>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
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 { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||
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 { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-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 { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
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 { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
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: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -68,9 +73,10 @@ function isRecurringEvent(event: CalendarEvent): boolean {
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const isMobile = useIsMobile();
|
||||
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 { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
@@ -96,6 +102,10 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = 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 [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
@@ -156,10 +166,10 @@ export default function CalendarPage() {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
} else if (client && !supportsCalendar) {
|
||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -167,6 +177,84 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
@@ -955,7 +1043,54 @@ export default function CalendarPage() {
|
||||
});
|
||||
}, [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 = () => {
|
||||
if (isLoading && calendars.length === 0) {
|
||||
@@ -1082,7 +1217,9 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
{!isMobile && (
|
||||
<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
|
||||
calendars={calendars}
|
||||
client={client}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
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} />
|
||||
{renderWebcalAccountPicker()}
|
||||
{renderWebcalActionChoice()}
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
@@ -1435,6 +1583,7 @@ export default function CalendarPage() {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -649,7 +650,9 @@ export default function ContactsPage() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
{!isMobile && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } 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";
|
||||
|
||||
export default function FilesPage() {
|
||||
@@ -374,7 +375,9 @@ export default function FilesPage() {
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
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 && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
@@ -514,6 +517,7 @@ export default function FilesPage() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -32,7 +33,9 @@ export default async function LocaleLayout({
|
||||
<RateLimitToastProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
{children}
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
{children}
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</RateLimitToastProvider>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||
|
||||
@@ -117,7 +116,7 @@ export default function LoginPage() {
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, 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 [formData, setFormData] = useState({
|
||||
@@ -532,7 +531,7 @@ export default function LoginPage() {
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||
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("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
+234
-53
@@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, 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 { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
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 { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
|
||||
@@ -73,6 +79,7 @@ export default function Home() {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
@@ -88,8 +95,10 @@ export default function Home() {
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
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();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
@@ -202,7 +211,7 @@ export default function Home() {
|
||||
|
||||
// Mobile/tablet responsive hooks
|
||||
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 {
|
||||
emails,
|
||||
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
|
||||
// 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).
|
||||
@@ -636,6 +652,7 @@ export default function Home() {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
|
||||
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
|
||||
if (parsed.emailListHeight) setEmailListHeight(parsed.emailListHeight);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -649,7 +666,78 @@ export default function Home() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -657,18 +745,14 @@ export default function Home() {
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||
await Promise.all([
|
||||
fetchMailboxes(client),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
// Get the selected mailbox (should be inbox by default)
|
||||
const state = useEmailStore.getState();
|
||||
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) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
@@ -676,34 +760,13 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch emails for the selected mailbox
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
// Fetch tag counts
|
||||
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) {
|
||||
console.error('Error loading email data:', error);
|
||||
}
|
||||
@@ -713,17 +776,33 @@ export default function Home() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
client.closePushNotifications();
|
||||
};
|
||||
}
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
|
||||
|
||||
// Cleanup push notifications on unmount
|
||||
return () => {
|
||||
if (client) {
|
||||
client.closePushNotifications();
|
||||
// Push notifications: set up once per client and tear down when the client
|
||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
||||
// above so it still runs when data was prefetched at login time.
|
||||
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
|
||||
// than one account is connected. Runs whenever the set of connected accounts
|
||||
@@ -875,6 +954,7 @@ export default function Home() {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
envelopeMailFrom?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
@@ -885,7 +965,7 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
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);
|
||||
|
||||
// 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 () => {
|
||||
if (!client) return;
|
||||
try {
|
||||
@@ -1604,9 +1721,30 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const primaryIdentity = identities[0];
|
||||
const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity;
|
||||
|
||||
// Append signature from the primary identity
|
||||
const finalBody = appendPlainTextSignature(body, primaryIdentity);
|
||||
// Decide the sending identity and (for domain-catch-all) an optional
|
||||
// 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;
|
||||
|
||||
@@ -1624,14 +1762,15 @@ export default function Home() {
|
||||
finalBody,
|
||||
undefined,
|
||||
undefined,
|
||||
primaryIdentity?.id,
|
||||
primaryIdentity?.email,
|
||||
sendingIdentity?.id,
|
||||
headerFromEmail,
|
||||
undefined,
|
||||
primaryIdentity?.name || undefined,
|
||||
headerFromName,
|
||||
undefined,
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
envelopeMailFrom,
|
||||
);
|
||||
|
||||
// Mark the original email as answered
|
||||
@@ -1660,9 +1799,11 @@ export default function Home() {
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
const handleEmailSelect = async (email: { id: string }) => {
|
||||
@@ -1819,7 +1960,8 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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" />
|
||||
@@ -1869,7 +2011,7 @@ export default function Home() {
|
||||
"flex-shrink-0 h-full z-50",
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
// 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",
|
||||
!sidebarOpen && "max-lg:-translate-x-full",
|
||||
// Desktop: normal flow
|
||||
@@ -1894,6 +2036,7 @@ export default function Home() {
|
||||
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||
onImportEmail={handleImportEmailFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
@@ -1920,21 +2063,30 @@ export default function Home() {
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
|
||||
<div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
|
||||
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
|
||||
<div
|
||||
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
|
||||
"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",
|
||||
// Tablet/Desktop: fixed width with collapse animation
|
||||
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
|
||||
"md:shadow-sm",
|
||||
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
|
||||
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",
|
||||
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 */}
|
||||
<MobileHeader
|
||||
@@ -2243,7 +2395,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
@@ -2251,17 +2403,29 @@ export default function Home() {
|
||||
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 */}
|
||||
<div
|
||||
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
|
||||
"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",
|
||||
// Tablet/Desktop: relative
|
||||
"md:relative",
|
||||
shouldHideViewerPane && "md:hidden"
|
||||
shouldHideViewerPane && "md:hidden",
|
||||
shouldHideHorizontalViewerPane && "md:hidden"
|
||||
)}
|
||||
>
|
||||
{/* Inline Composer - shown in viewer pane */}
|
||||
@@ -2293,7 +2457,13 @@ export default function Home() {
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
onSaveState={(data) => setPendingDraft(data)}
|
||||
onSaveState={(data) => {
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
@@ -2448,6 +2618,17 @@ export default function Home() {
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
|
||||
<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} />
|
||||
<PromptDialog {...promptDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
|
||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-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 { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
@@ -98,6 +101,7 @@ type Tab =
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'protocol_handlers'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
@@ -133,6 +137,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
@@ -192,6 +197,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.attachment_reminder',
|
||||
'settings.email_behavior.auto_select_reply_identity',
|
||||
'settings.email_behavior.default_mail_program',
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
identities: ['settings.identities'],
|
||||
@@ -210,6 +216,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
calendar: ['calendar.settings', 'calendar.management'],
|
||||
contacts: ['settings.contacts', 'contacts'],
|
||||
files: ['settings.files'],
|
||||
protocol_handlers: ['protocol_handlers'],
|
||||
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
|
||||
about_data: ['settings.advanced'],
|
||||
themes: [],
|
||||
@@ -239,6 +246,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
files: 'attachments cloud drive storage upload',
|
||||
protocol_handlers: 'mailto webcal links default app protocol handler',
|
||||
sidebar_apps: 'apps webview iframe',
|
||||
about_data: 'export import storage quota privacy backup',
|
||||
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: 'language', label: t('tabs.language'), icon: tabIcons.language, 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
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
@@ -582,7 +591,7 @@ export default function SettingsPage() {
|
||||
// Apps
|
||||
...(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' },
|
||||
...(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 }] : []),
|
||||
|
||||
// Advanced
|
||||
@@ -665,6 +674,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
@@ -677,7 +687,8 @@ export default function SettingsPage() {
|
||||
if (!isDesktop) {
|
||||
if (mobileShowContent) {
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -707,7 +718,8 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -817,7 +829,9 @@ export default function SettingsPage() {
|
||||
|
||||
// Desktop layout
|
||||
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)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -949,6 +963,7 @@ export default function SettingsPage() {
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
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';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
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 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 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" />
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
const IMAGE_FIELDS = [
|
||||
|
||||
@@ -26,7 +26,7 @@ export function DashboardTab() {
|
||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
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 [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
@@ -96,7 +96,9 @@ export function DashboardTab() {
|
||||
const sources = await adminConfigRes.json();
|
||||
setConfigSources(sources);
|
||||
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.');
|
||||
}
|
||||
const adminPassword = sources?.adminPassword;
|
||||
@@ -119,18 +121,6 @@ export function DashboardTab() {
|
||||
</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">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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 { isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
interface Extension {
|
||||
slug: string;
|
||||
@@ -94,6 +97,13 @@ export function MarketplaceTab() {
|
||||
}, [searchInput]);
|
||||
|
||||
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);
|
||||
setMessage(null);
|
||||
|
||||
@@ -258,6 +268,8 @@ function ExtensionCard({
|
||||
}) {
|
||||
const isPlugin = extension.type === 'plugin';
|
||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||
const versionMismatch = !!extension.minAppVersion
|
||||
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||
|
||||
return (
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
@@ -346,12 +358,20 @@ function ExtensionCard({
|
||||
</div>
|
||||
</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 ? (
|
||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Installed
|
||||
</span>
|
||||
) : 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
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
|
||||
@@ -30,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ 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: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
|
||||
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsTab() {
|
||||
|
||||
+36
-24
@@ -27,11 +27,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
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
|
||||
// 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 [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
@@ -177,6 +179,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
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 = (
|
||||
<>
|
||||
<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" />
|
||||
)}
|
||||
<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"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</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"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</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"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/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>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/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>
|
||||
)}
|
||||
<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">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<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"
|
||||
title="Settings"
|
||||
>
|
||||
@@ -411,7 +421,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<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"
|
||||
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>
|
||||
</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"
|
||||
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>
|
||||
</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"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
@@ -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>
|
||||
</div>
|
||||
<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"
|
||||
title="Settings"
|
||||
>
|
||||
|
||||
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<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 ? (
|
||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||
) : (
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
|
||||
) : (
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
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 {
|
||||
extension: {
|
||||
@@ -200,6 +203,7 @@ export default function MarketplacePreviewPage() {
|
||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
@@ -294,8 +298,11 @@ export default function MarketplacePreviewPage() {
|
||||
) : (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
disabled={installing || !!bundle.error || versionMismatch}
|
||||
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" />}
|
||||
Install
|
||||
@@ -310,6 +317,18 @@ export default function MarketplacePreviewPage() {
|
||||
</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 && (
|
||||
<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" />
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
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> = {
|
||||
'.svg': 'image/svg+xml',
|
||||
@@ -38,11 +41,11 @@ export async function GET(
|
||||
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);
|
||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
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 ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(BRANDING_DIR, safeName);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(BRANDING_DIR)) {
|
||||
await mkdir(BRANDING_DIR, { recursive: true });
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
|
||||
@@ -2,12 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
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 { 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)
|
||||
*
|
||||
* 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() {
|
||||
try {
|
||||
@@ -17,7 +28,19 @@ export async function GET() {
|
||||
await configManager.ensureLoaded();
|
||||
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' },
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -278,6 +278,12 @@ export async function POST(request: NextRequest) {
|
||||
enabled: true,
|
||||
installedAt: 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
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
|
||||
@@ -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: '/' },
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
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 {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
@@ -13,17 +18,20 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -70,13 +78,19 @@ export async function POST(request: NextRequest) {
|
||||
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 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 cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
@@ -185,8 +199,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Delete all session cookies (slots 0-4)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
// Delete all session cookies across every slot.
|
||||
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@ import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
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 { readFileEnv } from '@/lib/read-file-env';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
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('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (raw === null) return 0;
|
||||
|
||||
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) {
|
||||
@@ -56,7 +57,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
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, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
|
||||
@@ -4,12 +4,13 @@ import { logger } from '@/lib/logger';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
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 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';
|
||||
|
||||
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();
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
|
||||
const name = refreshTokenCookieName(i);
|
||||
const serverCookieName = refreshTokenServerCookieName(i);
|
||||
const token = cookieStore.get(name)?.value;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
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.
|
||||
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
|
||||
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;
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -35,8 +36,9 @@ export async function GET() {
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
|
||||
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
|
||||
// =====================================================================
|
||||
{
|
||||
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: [],
|
||||
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,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
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),
|
||||
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
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',
|
||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||
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),
|
||||
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' }],
|
||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
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),
|
||||
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',
|
||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||
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),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
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',
|
||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||
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),
|
||||
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: [],
|
||||
subject: 'Conference talk accepted!',
|
||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||
@@ -728,8 +728,8 @@ const IDENTITIES = [
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
@@ -743,6 +743,12 @@ const addressBooks = [
|
||||
{ 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 = [
|
||||
// --- Personal address book ---
|
||||
{ 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' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||
@@ -760,6 +767,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||
@@ -768,6 +776,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||
@@ -775,6 +784,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+31 20 555 0142' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||
@@ -783,6 +793,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||
@@ -791,6 +802,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||
@@ -798,6 +810,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||
@@ -806,6 +819,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'BergLabs' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||
@@ -814,6 +828,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||
@@ -822,6 +837,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||
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.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Work address book ---
|
||||
{ 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' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||
@@ -839,6 +856,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||
@@ -847,6 +865,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||
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.' } },
|
||||
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',
|
||||
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' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||
@@ -863,6 +883,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Digitaal BV' } },
|
||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||
@@ -871,6 +892,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||
@@ -879,6 +901,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||
@@ -887,6 +910,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Ferreira Media' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||
@@ -895,6 +919,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||
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.' } },
|
||||
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',
|
||||
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' } },
|
||||
nicknames: { n1: { name: 'Anni' } },
|
||||
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 ---
|
||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||
@@ -976,7 +1002,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
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'),
|
||||
},
|
||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||
@@ -986,7 +1012,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
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'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
},
|
||||
@@ -1024,7 +1050,7 @@ const calendarEvents = [
|
||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||
participants: {
|
||||
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'),
|
||||
},
|
||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||
@@ -1054,7 +1080,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
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'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
@@ -1066,7 +1092,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
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', {
|
||||
@@ -1084,7 +1110,7 @@ const calendarEvents = [
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.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', {
|
||||
@@ -1093,7 +1119,7 @@ const calendarEvents = [
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@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', {
|
||||
@@ -1109,7 +1135,7 @@ const calendarEvents = [
|
||||
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.',
|
||||
participants: {
|
||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
||||
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
@@ -1192,7 +1218,7 @@ const calendarEvents = [
|
||||
}),
|
||||
|
||||
// ===== 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,
|
||||
recurrence: [{ frequency: 'yearly' }],
|
||||
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!',
|
||||
participants: {
|
||||
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'),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
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) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let currentUrl = cleanUrl;
|
||||
const originalOrigin = new URL(cleanUrl).origin;
|
||||
let response: Response | undefined;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 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
|
||||
*
|
||||
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
|
||||
// StateChange so multi-account browsers fetch from the right slot. Older
|
||||
// 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`, {
|
||||
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 { apiUrl, accountId } = target;
|
||||
|
||||
const inboxRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
|
||||
const jmapRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
|
||||
@@ -6,7 +6,8 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
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 } {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
@@ -49,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -59,7 +63,7 @@ function normalizeUrl(url: string): string {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
||||
* 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 normalizedServerUrl = normalizeUrl(serverUrl);
|
||||
|
||||
for (let slot = 0; slot <= 4; slot++) {
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
// Check basic-auth session cookie
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (token) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' } },
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -171,8 +171,15 @@ body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
"Noto Sans Thai",
|
||||
"Leelawadee UI",
|
||||
Tahoma,
|
||||
sans-serif;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
|
||||
@@ -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
@@ -1,9 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -16,8 +17,15 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
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 {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
@@ -30,7 +38,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
icons: { icon: faviconUrl },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -2,13 +2,26 @@ import type { MetadataRoute } from "next";
|
||||
|
||||
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
|
||||
// against the document origin, not the manifest's location, and Next.js does
|
||||
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
export default function manifest(): ExtendedManifest {
|
||||
const appName =
|
||||
process.env.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-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"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")} />;
|
||||
}
|
||||
@@ -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")} />;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
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 tCal = useTranslations("calendar");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("file");
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
|
||||
const [urlInput, setUrlInput] = useState(initialUrl || "");
|
||||
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
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 tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
|
||||
|
||||
const isEdit = !!editSubscription;
|
||||
|
||||
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||
const [name, setName] = useState(editSubscription?.name || "");
|
||||
const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
|
||||
const [name, setName] = useState(editSubscription?.name || initialName || "");
|
||||
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionNotice(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
|
||||
if (events.length > 0) {
|
||||
const parsed = events[0];
|
||||
setParsedEvent(parsed);
|
||||
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
|
||||
try {
|
||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||
const rawText = await blob.text();
|
||||
const icsMethod = extractMethodFromRawIcs(rawText);
|
||||
if (icsMethod !== 'unknown') {
|
||||
setRawIcsMethod(icsMethod);
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in
|
||||
// parallel with parsing to save a roundtrip.
|
||||
const [events, rawText] = await Promise.all([
|
||||
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
||||
(async () => {
|
||||
try {
|
||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||
return await blob.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch { /* ignore - fall back to heuristic detection */ }
|
||||
})(),
|
||||
]);
|
||||
|
||||
if (parsed.uid && supportsCalendar) {
|
||||
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 {
|
||||
if (events.length === 0) {
|
||||
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 {
|
||||
setState('error');
|
||||
|
||||
@@ -32,9 +32,10 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
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 */
|
||||
function htmlToPlainText(html: string): string {
|
||||
@@ -55,6 +56,10 @@ export interface ComposerDraftData {
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: EmailComposerProps['replyTo'];
|
||||
draftId: string | null;
|
||||
/** When set, overrides the header From: - sent through the selected identity's envelope. */
|
||||
fromOverrideEmail?: string;
|
||||
fromOverrideName?: string;
|
||||
fromOverrideEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface EmailComposerProps {
|
||||
@@ -69,6 +74,7 @@ interface EmailComposerProps {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
envelopeMailFrom?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
@@ -111,6 +117,39 @@ type ComposerAttachment = {
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${startMarker}<p>${escaped}</p>${endMarker}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
@@ -130,6 +169,25 @@ export function EmailComposer({
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
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
|
||||
const getInitialTo = () => {
|
||||
@@ -179,10 +237,19 @@ export function EmailComposer({
|
||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||
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') {
|
||||
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') {
|
||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -194,20 +261,25 @@ export function EmailComposer({
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><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) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
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') {
|
||||
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;
|
||||
@@ -245,6 +317,9 @@ export function EmailComposer({
|
||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
|
||||
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 [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
@@ -276,24 +351,96 @@ export function EmailComposer({
|
||||
});
|
||||
|
||||
const { client } = useAuthStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || 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(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
to: replyTo?.to,
|
||||
cc: replyTo?.cc,
|
||||
bcc: replyTo?.bcc,
|
||||
});
|
||||
|
||||
if (matchedIdentityId) {
|
||||
setSelectedIdentityId(matchedIdentityId);
|
||||
if (resolved) {
|
||||
setSelectedIdentityId(resolved.identityId);
|
||||
if (resolved.overrideEmail && !fromOverrideEnabled) {
|
||||
setFromOverrideEnabled(true);
|
||||
setFromOverrideEmail(resolved.overrideEmail);
|
||||
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -312,6 +459,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
fromOverrideEnabled,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
@@ -322,10 +470,10 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
@@ -361,8 +509,8 @@ export function EmailComposer({
|
||||
}, [currentSmimeIdentityId]);
|
||||
|
||||
// Keep a ref to current state for the unmount save
|
||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
|
||||
stateRef.current = { 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, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
|
||||
|
||||
// Track initial values for dirty detection (captured once on first render)
|
||||
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
|
||||
// Generate sub-addressed email if tag is set
|
||||
const fromEmail = currentIdentity?.email
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? fromOverrideEmail.trim()
|
||||
: identityFromEmail;
|
||||
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
|
||||
try {
|
||||
const savedDraftId = await client.createDraft(
|
||||
@@ -772,7 +926,7 @@ export function EmailComposer({
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
uploadedAttachments,
|
||||
currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
plainTextMode ? undefined : body
|
||||
);
|
||||
|
||||
@@ -945,20 +1099,39 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
const fromEmail = currentIdentity?.email
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: 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).
|
||||
// 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)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (currentIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||
if (signatureAlreadyInBody) return '';
|
||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (currentIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
if (signatureIdentity?.textSignature) {
|
||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -969,9 +1142,10 @@ export function EmailComposer({
|
||||
: null;
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const finalBody = plainTextMode
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
@@ -1006,6 +1180,12 @@ export function EmailComposer({
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
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
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
@@ -1150,8 +1330,9 @@ export function EmailComposer({
|
||||
htmlBody: outgoing.htmlBody || undefined,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
identityId: outgoing.identityId || currentIdentity?.id,
|
||||
envelopeMailFrom,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
@@ -1179,7 +1360,7 @@ export function EmailComposer({
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
// 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) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(t('send_failed'));
|
||||
@@ -1190,7 +1371,7 @@ export function EmailComposer({
|
||||
if (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?.();
|
||||
};
|
||||
|
||||
@@ -1200,7 +1381,7 @@ export function EmailComposer({
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
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?.();
|
||||
};
|
||||
|
||||
@@ -1212,7 +1393,7 @@ export function EmailComposer({
|
||||
if (draftId && onDiscardDraft) {
|
||||
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?.();
|
||||
};
|
||||
|
||||
@@ -1296,7 +1477,25 @@ export function EmailComposer({
|
||||
<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>
|
||||
<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
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
@@ -1328,16 +1527,18 @@ export function EmailComposer({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
{subAddressTag && (
|
||||
{!fromOverrideEnabled && (
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
)}
|
||||
{!fromOverrideEnabled && subAddressTag && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1349,6 +1550,28 @@ export function EmailComposer({
|
||||
<X className="w-3 h-3" />
|
||||
</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>
|
||||
|
||||
@@ -1495,20 +1718,24 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plainTextMode ? (
|
||||
getPlainTextSignature(currentIdentity) ? (
|
||||
{/* Hide the visual signature preview when the signature has already been
|
||||
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">
|
||||
{'-- \n'}{getPlainTextSignature(currentIdentity)}
|
||||
{signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
|
||||
</div>
|
||||
) : null
|
||||
) : composerSignatureHtml ? (
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
@@ -1566,7 +1793,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { HoverAction } from "@/stores/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
@@ -76,11 +77,13 @@ export function EmailHoverActions({
|
||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const hoverBackgroundClassName = backgroundClassName;
|
||||
|
||||
if (isMobile) return null;
|
||||
if (hoverActions.length === 0) return null;
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback } from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 isFocusedMailLayout = mailLayout === 'focus';
|
||||
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
|
||||
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)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -160,11 +161,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -295,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function EmailList({
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
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];
|
||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||
|
||||
+738
-496
File diff suppressed because it is too large
Load Diff
@@ -127,7 +127,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
ref={triggerRef}
|
||||
onClick={handleOpen}
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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 Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
|
||||
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 {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
@@ -131,15 +179,23 @@ export function RichTextEditor({
|
||||
placeholder,
|
||||
className,
|
||||
hasError,
|
||||
onEditorReady,
|
||||
}: RichTextEditorProps) {
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
StyledHeading.configure({ levels: [1, 2] }),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
@@ -237,6 +293,12 @@ export function RichTextEditor({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
@@ -331,8 +331,12 @@ function EmailCard({
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
// 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.
|
||||
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
||||
if (hasTextBody && htmlContent) {
|
||||
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
|
||||
// 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);
|
||||
} else {
|
||||
useHtmlVersion = !!htmlContent;
|
||||
@@ -436,6 +440,49 @@ function EmailCard({
|
||||
return { html: "", isHtml: false };
|
||||
}, [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 (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||
@@ -479,7 +526,7 @@ function EmailCard({
|
||||
</div>
|
||||
{!isExpanded && density !== 'extra-compact' && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -530,18 +577,30 @@ function EmailCard({
|
||||
|
||||
{/* Email Body */}
|
||||
<div style={{ padding: 'var(--density-card-p)' }}>
|
||||
<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={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
{emailContent.isHtml ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
title="Email content"
|
||||
className="w-full border-0 block"
|
||||
style={{ minHeight: '60px' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Attachments */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -27,6 +28,7 @@ export function ThreadEmailItem({
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
@@ -177,7 +179,7 @@ export function ThreadEmailItem({
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/70"
|
||||
)}>
|
||||
{email.preview || "No preview"}
|
||||
{email.preview || t('no_preview_available')}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -52,6 +52,7 @@ interface SingleEmailItemProps {
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
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 isStarred = email.keywords?.$flagged;
|
||||
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 isChecked = selectedEmailIds.has(email.id);
|
||||
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
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
@@ -148,8 +150,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -178,11 +180,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -316,7 +318,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -359,6 +361,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsSpam,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -366,7 +369,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
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 getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
@@ -506,8 +510,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -562,11 +566,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -722,7 +726,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
{trimmedPreview || tEmailViewer('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -182,7 +182,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</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 && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<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 && (
|
||||
<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}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!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} />
|
||||
</div>
|
||||
)}
|
||||
@@ -211,6 +217,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
alt={name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg bg-background"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -220,6 +227,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
sandbox=""
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -229,6 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
type="application/pdf"
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
aria-label={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
@@ -238,14 +247,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
)}
|
||||
|
||||
{!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>
|
||||
<audio controls className="w-full" src={objectUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
|
||||
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-html-sig"
|
||||
maxLength={5000}
|
||||
maxLength={50000}
|
||||
value={formData.htmlSignature}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
|
||||
rows={5}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-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 { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
/** "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" }) {
|
||||
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 (
|
||||
<div
|
||||
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={account.label}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +48,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
@@ -115,9 +113,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
setDefaultAccount(accountId);
|
||||
};
|
||||
|
||||
// Display name for the active account
|
||||
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
|
||||
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
|
||||
// Show the account's own identity, not the preferred sending identity -
|
||||
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
||||
// the actually logged-in account (info@linusrath.de).
|
||||
const displayName = activeAccount?.displayName || activeAccount?.label || "";
|
||||
const displayEmail = activeAccount?.email || activeAccount?.username || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -220,7 +220,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
</div>
|
||||
|
||||
{/* Separator + Add Account */}
|
||||
{accounts.length < MAX_ACCOUNTS && (
|
||||
{accounts.length < getMaxAccounts() && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Pencil,
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
|
||||
onCreateFolder?: () => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
|
||||
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
|
||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||
const canAddItems = mailbox.myRights?.mayAddItems !== false;
|
||||
|
||||
const fullPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
icon={Upload}
|
||||
label={t("import_email")}
|
||||
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
|
||||
disabled={!onImportEmail || !canAddItems}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
icon={FolderX}
|
||||
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||
|
||||
@@ -11,18 +11,18 @@ import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
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 { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -168,7 +168,9 @@ export function NavigationRail({
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
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 showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
@@ -252,8 +254,8 @@ export function NavigationRail({
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
@@ -271,7 +273,7 @@ export function NavigationRail({
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<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"
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
@@ -609,7 +611,6 @@ export function NavigationRail({
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
@@ -617,15 +618,20 @@ export function NavigationRail({
|
||||
if (!isActive) switchAccount(account.id);
|
||||
}}
|
||||
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
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
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 && (
|
||||
<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" />
|
||||
@@ -634,7 +640,7 @@ export function NavigationRail({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{accounts.length < MAX_ACCOUNTS && (
|
||||
{accounts.length < getMaxAccounts() && (
|
||||
<button
|
||||
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"
|
||||
|
||||
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
|
||||
onResize: (delta: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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 startX = useRef(0);
|
||||
const startPos = useRef(0);
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
document.body.style.cursor = "col-resize";
|
||||
startPos.current = isHorizontal ? e.clientY : e.clientX;
|
||||
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
onResizeStart?.();
|
||||
}, [onResizeStart]);
|
||||
}, [onResizeStart, isHorizontal]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
let delta = 0;
|
||||
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
||||
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
||||
else return;
|
||||
if (isHorizontal) {
|
||||
if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
|
||||
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();
|
||||
onResize(delta);
|
||||
onResizeEnd?.();
|
||||
}, [onResize, onResizeEnd]);
|
||||
}, [onResize, onResizeEnd, isHorizontal]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const delta = e.clientX - startX.current;
|
||||
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
|
||||
onResize(delta);
|
||||
};
|
||||
|
||||
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [onResize, onResizeEnd]);
|
||||
}, [onResize, onResizeEnd, isHorizontal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
|
||||
aria-label="Resize"
|
||||
tabIndex={0}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
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",
|
||||
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
User,
|
||||
Users,
|
||||
Palmtree,
|
||||
Settings,
|
||||
X,
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
NotebookPen,
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -66,6 +71,7 @@ interface SidebarProps {
|
||||
onCreateFolder?: () => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
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 === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
|
||||
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 (hasChildren) {
|
||||
@@ -103,6 +114,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
|
||||
trash: "text-muted-foreground",
|
||||
junk: "text-red-600/80 dark:text-red-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 {
|
||||
@@ -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 === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -636,6 +657,7 @@ export function Sidebar({
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
@@ -1037,6 +1059,7 @@ export function Sidebar({
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
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 esMessages from '@/locales/es/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import jaMessages from '@/locales/ja/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 nlMessages from '@/locales/nl/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)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
da: daMessages,
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
es: esMessages,
|
||||
fr: frMessages,
|
||||
it: itMessages,
|
||||
ja: jaMessages,
|
||||
ko: koMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
lv: lvMessages,
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
|
||||
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
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}</>;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
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 { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
|
||||
{tTour('restart_button')}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
isSupportedSubAddressDelimiter,
|
||||
@@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
@@ -27,20 +23,11 @@ export function ComposingSettings() {
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
updateSetting,
|
||||
} = 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 (
|
||||
<SettingsSection title={t('title')} description={t('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 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
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
@@ -148,24 +153,6 @@ export function ComposingSettings() {
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
|
||||
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
|
||||
AlertTriangle, NotebookPen, CalendarClock, BellOff,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
|
||||
trash: Trash,
|
||||
junk: ShieldAlert,
|
||||
archive: Archive,
|
||||
shared: Users,
|
||||
important: AlertTriangle,
|
||||
memos: NotebookPen,
|
||||
scheduled: CalendarClock,
|
||||
snoozed: BellOff,
|
||||
};
|
||||
|
||||
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
|
||||
|
||||
@@ -13,6 +13,12 @@ const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ 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({
|
||||
value,
|
||||
t,
|
||||
@@ -20,8 +26,6 @@ function MailLayoutPreview({
|
||||
value: MailLayout;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const isSplit = value === 'split';
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||
<div>
|
||||
@@ -33,7 +37,7 @@ function MailLayoutPreview({
|
||||
<div className="flex h-28">
|
||||
<div className="w-11 border-r border-border bg-muted/40" />
|
||||
|
||||
{isSplit ? (
|
||||
{value === 'split' && (
|
||||
<>
|
||||
<div className="w-28 border-r border-border bg-background">
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<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) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px]',
|
||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
@@ -74,6 +99,11 @@ function MailLayoutPreview({
|
||||
</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>
|
||||
@@ -100,6 +130,7 @@ export function LayoutSettings() {
|
||||
options={[
|
||||
{ value: 'split', label: tEmail('mail_layout.split') },
|
||||
{ value: 'focus', label: tEmail('mail_layout.focus') },
|
||||
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
|
||||
]}
|
||||
/>
|
||||
<MailLayoutPreview value={mailLayout} t={tEmail} />
|
||||
|
||||
@@ -52,11 +52,14 @@ export function NotificationSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!client) return;
|
||||
const accountId = client.getAccountId();
|
||||
if (!accountId) return;
|
||||
void (async () => {
|
||||
const enabled = await isWebPushEnabled();
|
||||
if (enabled) setPushStatus({ kind: 'enabled' });
|
||||
const enabled = await isWebPushEnabled(accountId);
|
||||
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
|
||||
})();
|
||||
}, [supported]);
|
||||
}, [supported, client]);
|
||||
|
||||
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
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 [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
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 {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
|
||||
if (legacy && !tourCompleted) {
|
||||
updateSetting("tourCompleted", true);
|
||||
}
|
||||
} 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(() => {
|
||||
let resumeStep = 0;
|
||||
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const completeTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setHasCompletedTour(true);
|
||||
updateSetting("tourCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const resetTourCompletion = useCallback(() => {
|
||||
setHasCompletedTour(false);
|
||||
updateSetting("tourCompleted", false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const value: TourContextValue = {
|
||||
isActive,
|
||||
|
||||
@@ -142,9 +142,13 @@ interface AvatarProps {
|
||||
className?: string;
|
||||
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
|
||||
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 [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||
@@ -189,10 +193,17 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
|
||||
const getInitials = () => {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
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 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
|
||||
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],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
|
||||
title={name || email}
|
||||
>
|
||||
{imgSrc ? (
|
||||
|
||||
@@ -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 */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||
cs: FlagCS,
|
||||
da: FlagDK,
|
||||
de: FlagDE,
|
||||
en: FlagGB,
|
||||
es: FlagES,
|
||||
fr: FlagFR,
|
||||
it: FlagIT,
|
||||
ja: FlagJP,
|
||||
ko: FlagKR,
|
||||
es: FlagES,
|
||||
it: FlagIT,
|
||||
de: FlagDE,
|
||||
lv: FlagLV,
|
||||
nl: FlagNL,
|
||||
pl: FlagPL,
|
||||
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
cs: FlagCS,
|
||||
};
|
||||
|
||||
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
{ 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: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
|
||||
const t = useTranslations("welcome");
|
||||
const router = useRouter();
|
||||
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 [dismissed, setDismissed] = useState(false);
|
||||
|
||||
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 {
|
||||
if (!localStorage.getItem(ONBOARDING_KEY)) {
|
||||
setVisible(true);
|
||||
const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
|
||||
if (legacy && !onboardingCompleted) {
|
||||
updateSetting("onboardingCompleted", true);
|
||||
}
|
||||
} 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(() => {
|
||||
setDismissed(true);
|
||||
updateSetting("onboardingCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_KEY, "true");
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
+7
-1
@@ -11,8 +11,13 @@ services:
|
||||
volumes:
|
||||
# Encrypted user settings (SETTINGS_DATA_DIR).
|
||||
- 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
|
||||
# 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).
|
||||
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
|
||||
- bulwark-telemetry:/app/data/telemetry
|
||||
@@ -35,4 +40,5 @@ services:
|
||||
volumes:
|
||||
bulwark-settings:
|
||||
bulwark-admin:
|
||||
bulwark-admin-state:
|
||||
bulwark-telemetry:
|
||||
|
||||
@@ -45,6 +45,7 @@ export default [
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
@@ -77,6 +78,7 @@ export default [
|
||||
"*.config.mjs",
|
||||
"e2e/**",
|
||||
"local-data/**/*.mjs",
|
||||
"benchmark/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface ConfigData {
|
||||
oauthOnly: boolean;
|
||||
oauthClientId: string;
|
||||
oauthIssuerUrl: string;
|
||||
oauthScopes: string;
|
||||
rememberMeEnabled: boolean;
|
||||
settingsSyncEnabled: boolean;
|
||||
stalwartFeaturesEnabled: boolean;
|
||||
@@ -90,6 +91,7 @@ export function useConfig(): AppConfig {
|
||||
oauthOnly: configCache?.oauthOnly || false,
|
||||
oauthClientId: configCache?.oauthClientId || '',
|
||||
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
|
||||
oauthScopes: configCache?.oauthScopes || '',
|
||||
rememberMeEnabled: configCache?.rememberMeEnabled || false,
|
||||
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
|
||||
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
|
||||
@@ -124,6 +126,7 @@ export function useConfig(): AppConfig {
|
||||
oauthOnly: configCache.oauthOnly,
|
||||
oauthClientId: configCache.oauthClientId,
|
||||
oauthIssuerUrl: configCache.oauthIssuerUrl,
|
||||
oauthScopes: configCache.oauthScopes,
|
||||
rememberMeEnabled: configCache.rememberMeEnabled,
|
||||
settingsSyncEnabled: configCache.settingsSyncEnabled,
|
||||
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
|
||||
@@ -159,6 +162,7 @@ export function useConfig(): AppConfig {
|
||||
oauthOnly: data.oauthOnly,
|
||||
oauthClientId: data.oauthClientId,
|
||||
oauthIssuerUrl: data.oauthIssuerUrl,
|
||||
oauthScopes: data.oauthScopes,
|
||||
rememberMeEnabled: data.rememberMeEnabled,
|
||||
settingsSyncEnabled: data.settingsSyncEnabled,
|
||||
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
|
||||
|
||||
+5
-2
@@ -14,8 +14,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'cs':
|
||||
messages = (await import('../locales/cs/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
case 'da':
|
||||
messages = (await import('../locales/da/common.json')).default;
|
||||
break;
|
||||
case 'de':
|
||||
messages = (await import('../locales/de/common.json')).default;
|
||||
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'es':
|
||||
messages = (await import('../locales/es/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
break;
|
||||
case 'it':
|
||||
messages = (await import('../locales/it/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
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',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
+31
-3
@@ -1,6 +1,9 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { configManager } from "./lib/admin/config-manager";
|
||||
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(
|
||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
|
||||
const current: string = pkg.version ?? "0.0.0";
|
||||
console.info(`Bulwark Webmail v${current}`);
|
||||
|
||||
// Initialize admin config and password bootstrap
|
||||
configManager.load()
|
||||
// Initialize admin config and password bootstrap. Migration runs first so
|
||||
// existing v1 layouts are split before anything reads admin.json.
|
||||
migrateLegacyAdminLayout()
|
||||
.then(() => configManager.load())
|
||||
.then(() => initAdminPassword())
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
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 () => {
|
||||
// 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><script>alert('x') & "q"</script></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>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
|
||||
expect(clean).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should remove images from signatures', () => {
|
||||
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
|
||||
it('should allow img with https src', () => {
|
||||
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
|
||||
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).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', () => {
|
||||
@@ -113,16 +169,17 @@ describe('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 signatureClean = sanitizeSignatureHtml(html);
|
||||
|
||||
// Email allows img and table
|
||||
expect(emailClean).toContain('<img');
|
||||
// Email allows table
|
||||
expect(emailClean).toContain('<table>');
|
||||
|
||||
// Signature blocks img but may allow some tables (verify in implementation)
|
||||
expect(signatureClean).not.toContain('<img');
|
||||
// Signature blocks table and video
|
||||
expect(signatureClean).not.toContain('<table');
|
||||
expect(signatureClean).not.toContain('<video');
|
||||
expect(signatureClean).toContain('Text');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
ImpersonationJwtError,
|
||||
verifyImpersonationJwt,
|
||||
impersonationReplayCache,
|
||||
} from '@/lib/impersonation/jwt';
|
||||
|
||||
const SECRET = 'a'.repeat(64);
|
||||
const ISSUER = 'platform-api/webmail';
|
||||
|
||||
function base64Url(input: Buffer | string): string {
|
||||
return Buffer.from(input)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
|
||||
const h = base64Url(JSON.stringify(header));
|
||||
const p = base64Url(JSON.stringify(payload));
|
||||
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
|
||||
return `${h}.${p}.${base64Url(sig)}`;
|
||||
}
|
||||
|
||||
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
iss: ISSUER,
|
||||
iat: now,
|
||||
exp: now + 120,
|
||||
jti: 'jti-' + Math.random().toString(36).slice(2),
|
||||
mailbox: 'alice@example.test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('verifyImpersonationJwt', () => {
|
||||
beforeEach(() => {
|
||||
impersonationReplayCache.clear();
|
||||
});
|
||||
|
||||
it('accepts a valid HS256 token', () => {
|
||||
const token = sign(basePayload());
|
||||
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
|
||||
expect(claims.mailbox).toBe('alice@example.test');
|
||||
});
|
||||
|
||||
it('rejects non-HS256 algorithms', () => {
|
||||
const header = { alg: 'none', typ: 'JWT' };
|
||||
const h = base64Url(JSON.stringify(header));
|
||||
const p = base64Url(JSON.stringify(basePayload()));
|
||||
const token = `${h}.${p}.`;
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
|
||||
});
|
||||
|
||||
it('rejects tokens with a forged signature', () => {
|
||||
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
|
||||
});
|
||||
|
||||
it('rejects when secret is too short', () => {
|
||||
const token = sign(basePayload());
|
||||
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
|
||||
});
|
||||
|
||||
it('rejects expired tokens', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
|
||||
});
|
||||
|
||||
it('rejects tokens with lifetime over the 300s ceiling', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
|
||||
});
|
||||
|
||||
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
|
||||
const token = sign(basePayload({ iss: 'someone-else' }));
|
||||
expect(() =>
|
||||
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
|
||||
).toThrowError(/issuer/i);
|
||||
});
|
||||
|
||||
it("rejects mailbox containing '%'", () => {
|
||||
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
|
||||
});
|
||||
|
||||
it("rejects mailbox containing ':'", () => {
|
||||
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
|
||||
});
|
||||
|
||||
it('rejects malformed tokens', () => {
|
||||
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
|
||||
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
|
||||
});
|
||||
|
||||
it('honours nbf with skew', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = sign(basePayload({ nbf: now + 600 }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('impersonationReplayCache', () => {
|
||||
beforeEach(() => {
|
||||
impersonationReplayCache.clear();
|
||||
});
|
||||
|
||||
it('accepts a jti once and rejects it on second use', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
|
||||
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('prunes expired jtis on next consume', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
|
||||
// Far in the future — pruning should clear the old entry.
|
||||
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
|
||||
// Re-using the old jti is allowed after pruning (security irrelevant since
|
||||
// the token would fail signature/exp validation upstream).
|
||||
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user