Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9db7b6b55f | ||
|
|
1460706e60 | ||
|
|
27d624758a | ||
|
|
5288821605 | ||
|
|
58e3ecc97a | ||
|
|
38c0694a08 | ||
|
|
71d25bb62f | ||
|
|
ef825b801a | ||
|
|
50cbd66bfd | ||
|
|
d564c874a3 | ||
|
|
568abb0e33 | ||
|
|
44781f002c | ||
|
|
941fa15251 | ||
|
|
60c7bd713e | ||
|
|
6f193e0c24 | ||
|
|
6bb85d746c | ||
|
|
bbd43b5948 | ||
|
|
180331805f | ||
|
|
9e96b1c24e | ||
|
|
40b4b26074 | ||
|
|
9863b9d88e | ||
|
|
75602b6a00 | ||
|
|
22da11514b | ||
|
|
9953557af0 | ||
|
|
8f7066d194 | ||
|
|
75c02f443f | ||
|
|
31100b8f87 | ||
|
|
152ec99262 | ||
|
|
ce401c0f59 | ||
|
|
3035fb046f | ||
|
|
4659b81538 | ||
|
|
c78dbee60b | ||
|
|
4b4c801148 | ||
|
|
2e4d3d4bc6 | ||
|
|
26ccf9e3b4 | ||
|
|
bc322a1e69 | ||
|
|
6ee3849463 | ||
|
|
abb249e5df | ||
|
|
0352312f25 | ||
|
|
ae66f8d89d | ||
|
|
55be19ede7 | ||
|
|
b508551d02 | ||
|
|
7ee329e046 | ||
|
|
1512b9afc0 | ||
|
|
ad48f4394a | ||
|
|
8d79145dba | ||
|
|
b821f8cc27 | ||
|
|
bebb394f54 | ||
|
|
2ba0003e16 | ||
|
|
4c1d0931a1 | ||
|
|
5a70cf95e0 | ||
|
|
66c5f0f52c | ||
|
|
8353b28b33 | ||
|
|
229992853b | ||
|
|
f0d87d594a | ||
|
|
196e51e91b | ||
|
|
0879030dc8 | ||
|
|
05e2837f6b | ||
|
|
241544cd08 | ||
|
|
7341e47a1b | ||
|
|
f238ca5898 | ||
|
|
00b40fc48a | ||
|
|
856496715b | ||
|
|
eb7eeae1ac | ||
|
|
6b1a99a70b | ||
|
|
1da04c254b | ||
|
|
8ae5ecba41 | ||
|
|
4ff05bd4ec | ||
|
|
31e96d6a46 | ||
|
|
82be047708 | ||
|
|
7d1fb73290 | ||
|
|
2818a16f06 | ||
|
|
e93dd44111 | ||
|
|
e86183b44a | ||
|
|
ad80aa23ca | ||
|
|
3a8daf8bff | ||
|
|
f8e7cce85a | ||
|
|
62ebe443f9 | ||
|
|
3c0faba837 | ||
|
|
956acb69ce | ||
|
|
e2abc8dee9 | ||
|
|
42ec34be21 | ||
|
|
534894c38d | ||
|
|
537707d9ed | ||
|
|
afe1e5a67c | ||
|
|
d13934c2a6 | ||
|
|
acc90eb455 | ||
|
|
5aa6d7a2f0 | ||
|
|
c46de636e0 | ||
|
|
8de8babba5 | ||
|
|
63f2169ae7 | ||
|
|
e843ef0ebb | ||
|
|
4b463f9691 | ||
|
|
58e4a3d117 | ||
|
|
e3f6ae874d | ||
|
|
52f5a5b42c | ||
|
|
e7bded82fb | ||
|
|
3ac14ecf38 | ||
|
|
0dca019fe5 | ||
|
|
ca0d6805cf | ||
|
|
8bcb487442 | ||
|
|
0245ec67e1 | ||
|
|
8810a63262 | ||
|
|
d3778e6521 | ||
|
|
1fc6185002 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec |
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
# LOGIN_WEBSITE_URL=https://example.com
|
||||
|
||||
# Per-domain branding overrides. Each entry must have "host" (exact or
|
||||
# "*.subdomain" wildcard) plus any subset of branding fields to override.
|
||||
# Unset fields fall through to the global values above.
|
||||
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
|
||||
|
||||
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
@@ -220,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-domain branding overrides (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When you serve the webmail on multiple hostnames, each hostname can override
|
||||
# a subset of branding fields. Unset fields fall back to the global values
|
||||
# above. Match is on the request's Host (or X-Forwarded-Host) header.
|
||||
#
|
||||
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
|
||||
# matches mail.example.com and any deeper subdomain, but NOT example.com).
|
||||
# Exact matches always win over wildcards; the longest wildcard suffix wins
|
||||
# among multiple wildcard matches.
|
||||
#
|
||||
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
|
||||
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
|
||||
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
|
||||
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
|
||||
#
|
||||
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
|
||||
# The env-var form is provided for stateless deployments.
|
||||
#
|
||||
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
|
||||
|
||||
# =============================================================================
|
||||
# Extension Directory / Marketplace
|
||||
# =============================================================================
|
||||
@@ -229,6 +257,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
# These are build-time variables - to change them with the published Docker
|
||||
# image, rebuild it with --build-arg (see README "Default UI locale").
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
|
||||
# Values: never (default) | always | as-needed
|
||||
# NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
|
||||
# =============================================================================
|
||||
# Legacy Build-time Variables (still supported as fallback)
|
||||
# =============================================================================
|
||||
|
||||
+106
@@ -1,5 +1,109 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.3 (2026-06-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
|
||||
- **Mail**: Preview composer attachments inline (click to open)
|
||||
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
|
||||
- **Mail**: Read receipts (MDN, RFC 8098)
|
||||
- **Mail**: Editable, layout-preserving quote island when replying
|
||||
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
|
||||
- **Calendar**: Per-viewer colors for shared calendars (#345)
|
||||
- **Filters**: Extended filter rules — attachment field and multi-value conditions
|
||||
- **Settings**: New built-in themes — Aurora Glass and Elastic
|
||||
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
|
||||
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
|
||||
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
|
||||
- **Admin**: Toggle for search-engine indexing (robots)
|
||||
- **Admin**: `passwordHashFile` in `admin.json`
|
||||
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
|
||||
- **PWA**: Configurable install screenshots (per-domain)
|
||||
- **i18n**: Hungarian locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
|
||||
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
|
||||
- **Mail**: Empty Trash for shared and group folders (#387)
|
||||
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
|
||||
- **Mail**: Preserve the HTML signature when sending a quick reply
|
||||
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
|
||||
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
|
||||
- **Mail**: No more 404 console spam for missing sender favicons
|
||||
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
|
||||
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
|
||||
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
|
||||
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
|
||||
- **Themes**: Plugin slot iframes inherit host font and color tokens
|
||||
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
|
||||
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
|
||||
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
|
||||
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
|
||||
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
|
||||
- **UI**: Close pane gaps by centering the resize handle on the seam
|
||||
- **Settings**: Fix section gears permanently hijacking the active tab
|
||||
|
||||
## 1.7.2 (2026-05-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Scheduled send and send delay (#322)
|
||||
- **Mail**: Drag emails out to the file explorer as `.eml`
|
||||
- **Mail**: Import emails from `.zip` archives
|
||||
- **Mail**: "Move to Trash and mark as read" delete action (#323)
|
||||
- **Mail**: Include group inboxes in the unified mailbox view (#328)
|
||||
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
|
||||
- **Mail**: Allow drag-and-drop into shared mailboxes
|
||||
- **Composer**: Ctrl/Cmd+Enter sends the open draft
|
||||
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
|
||||
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
|
||||
- **Settings**: Post-export action (keep / archive / trash)
|
||||
- **Settings**: Template for multi-email `.zip` filenames
|
||||
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
|
||||
- **Admin**: Policy-controlled push relay URL with optional user lock
|
||||
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
|
||||
- **Mail**: Report real upload progress with XHR progress events (#333)
|
||||
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
|
||||
- **Mail**: Match user-avatar treatment on quick reply
|
||||
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
|
||||
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
|
||||
- **Composer**: Stop closing the form when editing any field
|
||||
- **Pro**: Keep the empty viewer pane visible in the split layout
|
||||
- **Pro**: Prevent an empty main pane when reordering tabs across panes
|
||||
- **Mobile**: Collapse focus mail layout to multi-line
|
||||
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
|
||||
- **Calendar**: Align continued multi-week events with the week's left edge
|
||||
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
|
||||
- **Calendar**: Convert `recurrenceRules` to singular in batch create
|
||||
- **Calendar**: Handle malformed event dates (#316)
|
||||
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
|
||||
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
|
||||
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
|
||||
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve inline images when replying (#163)
|
||||
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||
|
||||
## 1.7.0 (2026-05-21)
|
||||
|
||||
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||
@@ -21,6 +125,7 @@
|
||||
- **Pro**: Multi-account contacts and a cross-account file picker
|
||||
- **Pro**: Composer From dropdown grouped by account
|
||||
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
||||
- **Plugins**: Marketplace update flow for installed plugins and themes
|
||||
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||
- **Account**: List and reorder logged-in accounts from settings (#282)
|
||||
@@ -62,6 +167,7 @@
|
||||
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
||||
- **Plugins**: Trust the directory version on marketplace install and update
|
||||
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
||||
|
||||
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||
# Accept-Language header does not match any supported locale. Baked in at
|
||||
# build time because next-intl wires it into client-side routing too.
|
||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
+14
-8
@@ -7,7 +7,10 @@
|
||||
- Unified mailbox view across all connected accounts
|
||||
- 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
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||
- Scheduled send and configurable send delay
|
||||
- Read receipts (MDN, RFC 8098)
|
||||
- Editable, layout-preserving quote island when replying
|
||||
- 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
|
||||
@@ -36,7 +39,7 @@
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
@@ -52,7 +55,7 @@
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
@@ -60,7 +63,7 @@
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
@@ -72,7 +75,7 @@
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
@@ -85,6 +88,7 @@
|
||||
|
||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
@@ -98,7 +102,7 @@
|
||||
|
||||
## Internationalization
|
||||
|
||||
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
@@ -118,7 +122,9 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
- 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
|
||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||
- Plugin 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`); install/uninstall restricted to the admin dashboard
|
||||
@@ -126,7 +132,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||
- 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
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -86,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: 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.
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# Per-domain overrides (optional). When the webmail is served on multiple
|
||||
# hostnames, each host can override any subset of the branding fields above.
|
||||
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
|
||||
# match any subdomain. Unset fields fall back to the global values.
|
||||
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -96,7 +96,7 @@ function OAuthCallbackInner() {
|
||||
// the refresh-token cookie write for the same reason.
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/sso/complete", {
|
||||
const res = await apiFetch("/api/auth/sso/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
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 { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
@@ -97,6 +98,9 @@ export default function CalendarPage() {
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
@@ -1030,10 +1034,47 @@ export default function CalendarPage() {
|
||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||
})();
|
||||
|
||||
// Apply each shared calendar's local color override (per-viewer recolor,
|
||||
// #345). The override replaces the calendar's color and wins over per-event
|
||||
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
|
||||
// calendars are passed through untouched.
|
||||
const displayCalendars = useMemo(() => {
|
||||
return calendars.map((cal) => {
|
||||
if (!cal.isShared) return cal;
|
||||
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
if (!override) return cal;
|
||||
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||
});
|
||||
}, [calendars, sharedCalendarColors]);
|
||||
|
||||
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||
// once per calendar (guarded by the presence of an existing key), and the
|
||||
// user can still overwrite it from the sidebar.
|
||||
useEffect(() => {
|
||||
const shared = calendars.filter((c) => c.isShared);
|
||||
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
|
||||
if (missing.length === 0) return;
|
||||
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||
// overrides so the picks stay distinct from what's already on screen.
|
||||
const used = new Set<string>();
|
||||
for (const c of calendars) {
|
||||
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
|
||||
}
|
||||
for (const color of Object.values(sharedCalendarColors)) {
|
||||
if (color) used.add(color.toLowerCase());
|
||||
}
|
||||
for (const cal of missing) {
|
||||
const color = pickUnusedCalendarColor(used);
|
||||
used.add(color.toLowerCase());
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
}
|
||||
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||
|
||||
const allCalendars = useMemo(() => {
|
||||
if (!showBirthdayCalendar) return calendars;
|
||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
if (!showBirthdayCalendar) return displayCalendars;
|
||||
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const filtered = events.filter((e) => {
|
||||
@@ -1219,7 +1260,7 @@ export default function CalendarPage() {
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
showCompleted={taskStore.showCompleted}
|
||||
@@ -1317,8 +1358,21 @@ export default function CalendarPage() {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
return;
|
||||
}
|
||||
// Shared calendars: recolor locally only (the viewer usually
|
||||
// can't write the owner's calendar, and it'd recolor it for
|
||||
// everyone). Personal calendars write through to the server.
|
||||
const cal = allCalendars.find((c) => c.id === calendarId);
|
||||
if (cal?.isShared) {
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onResetColor={(cal) => {
|
||||
// Drop the local override; the auto-assign effect picks a
|
||||
// fresh unused color (so it never reverts to a collision).
|
||||
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||
}}
|
||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||
onCreateEvent={(cal: Calendar) => {
|
||||
setDefaultCalendarIdForCreate(cal.id);
|
||||
@@ -1389,7 +1443,7 @@ export default function CalendarPage() {
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
isMobile={isMobile}
|
||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
@@ -1419,7 +1473,7 @@ export default function CalendarPage() {
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
@@ -1442,7 +1496,7 @@ export default function CalendarPage() {
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||
@@ -1529,7 +1583,7 @@ export default function CalendarPage() {
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
||||
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||
anchorRect={detailAnchorRect}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -1549,7 +1603,7 @@ export default function CalendarPage() {
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
@@ -1566,7 +1620,7 @@ export default function CalendarPage() {
|
||||
|
||||
{showImportModal && client && (
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
client={client}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ 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";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
@@ -48,9 +48,11 @@ export default function FilesPage() {
|
||||
supportsFiles,
|
||||
selectedResources,
|
||||
uploadProgress,
|
||||
migrationProgress,
|
||||
clipboard,
|
||||
initClient,
|
||||
checkSupport,
|
||||
migrateLegacyFlatNodes,
|
||||
navigate,
|
||||
navigateByPath,
|
||||
refresh,
|
||||
@@ -160,13 +162,16 @@ export default function FilesPage() {
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
useEffect(() => {
|
||||
if (storeClient && supportsFiles === null) {
|
||||
checkSupport().then((supported) => {
|
||||
checkSupport().then(async (supported) => {
|
||||
if (supported) {
|
||||
// Upgrade any files created by older builds (flat path-encoded names)
|
||||
// into the real FileNode hierarchy before the first listing.
|
||||
await migrateLegacyFlatNodes();
|
||||
navigate(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
|
||||
|
||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||
@@ -573,6 +578,32 @@ export default function FilesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Legacy file migration progress (issue #379) */}
|
||||
{migrationProgress && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("migration_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style={{ width: migrationProgress.total > 0
|
||||
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
||||
{migrationProgress.current} / {migrationProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
<PWAInstallPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
|
||||
@@ -11,10 +11,10 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
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 { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||
@@ -329,16 +329,27 @@ export default function LoginPage() {
|
||||
if (!oauthEnabled || !serverUrl) return;
|
||||
setOauthDiscoveryDone(false);
|
||||
setOauthMetadata(null);
|
||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||
const controller = new AbortController();
|
||||
// Discover via our own origin rather than fetching the IdP's /.well-known/*
|
||||
// documents directly from the browser. A direct cross-origin discovery
|
||||
// fetch is subject to CORS, and providers like Authentik serve those
|
||||
// documents without Access-Control-Allow-Origin, so the browser blocks the
|
||||
// response and login breaks (issue #382). The proxy runs discovery server
|
||||
// side where CORS does not apply.
|
||||
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
|
||||
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
|
||||
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
|
||||
.then((metadata) => {
|
||||
setOauthMetadata(metadata);
|
||||
setOauthDiscoveryDone(true);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
if (err?.name === "AbortError") return;
|
||||
setOauthMetadata(null);
|
||||
setOauthDiscoveryDone(true);
|
||||
});
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||
return () => controller.abort();
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
|
||||
|
||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||
const ssoError = searchParams.get("sso_error");
|
||||
@@ -722,7 +733,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -872,7 +883,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
/>
|
||||
|
||||
+480
-157
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
import MailPage from "@/app/(main)/[locale]/page";
|
||||
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||
@@ -172,7 +173,7 @@ export default function ProHome() {
|
||||
// enabled it. If either precondition stops holding, hand the user back
|
||||
// to the standard shell.
|
||||
if (isMobile || isTablet || !proInterface) {
|
||||
window.location.replace("/");
|
||||
window.location.replace(`${getPathPrefix()}/`);
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
SwatchBook,
|
||||
Download,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -59,6 +61,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
@@ -90,6 +93,7 @@ type Tab =
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
@@ -126,6 +130,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
@@ -141,7 +146,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
debug: Bug,
|
||||
};
|
||||
@@ -202,6 +207,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
@@ -236,6 +242,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
@@ -329,6 +336,14 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
try {
|
||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||
// Used only as the initial tab and intentionally NOT written to
|
||||
// 'settings-active-tab', so a gear click never becomes the persisted
|
||||
// default that the regular Settings button lands on. Cleared on mount.
|
||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||
if (deepLink) {
|
||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
||||
}
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
if (saved in LEGACY_TAB_MAP) {
|
||||
@@ -354,6 +369,11 @@ export default function SettingsPage() {
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||
// open, never the persisted default for future Settings-button clicks.
|
||||
useEffect(() => {
|
||||
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||
@@ -576,10 +596,12 @@ export default function SettingsPage() {
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
@@ -600,7 +622,6 @@ export default function SettingsPage() {
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
@@ -666,6 +687,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
@@ -925,7 +947,7 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
effectiveActiveTab === tab.id
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
const lastEmittedRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
if (value === lastEmittedRef.current) return;
|
||||
setDrafts(value.map(entryToDraft))
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||
lastEmittedRef.current = entries;
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ export function AuthTab() {
|
||||
<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" 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" />
|
||||
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import {
|
||||
BRANDING_OVERRIDE_KEYS,
|
||||
parseDomainBranding,
|
||||
type BrandingOverrideKey,
|
||||
type DomainBrandingEntry,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
interface ConfigEntry {
|
||||
value?: unknown;
|
||||
@@ -16,28 +22,67 @@ const IMAGE_FIELDS = [
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||
] as const;
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||
] as const;
|
||||
|
||||
const PWA_COLOR_FIELDS = [
|
||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||
] as const;
|
||||
|
||||
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
|
||||
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
// Tighter rule for uploads: wildcards can only point to externally-hosted
|
||||
// URLs, since we'd have no concrete subdomain to serve a file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||
() => parseDomainBranding(config['domainBranding']?.value),
|
||||
[config],
|
||||
);
|
||||
|
||||
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
|
||||
useEffect(() => {
|
||||
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
}
|
||||
}, [domainEntries, selectedHost]);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
@@ -45,29 +90,81 @@ export function BrandingTab() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function selectedEntry(): DomainBrandingEntry | null {
|
||||
if (!selectedHost) return null;
|
||||
return domainEntries.find(e => e.host === selectedHost) ?? null;
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key] as string;
|
||||
if (key in edits) return edits[key];
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
|
||||
}
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
function isOverriddenInScope(key: string): boolean {
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
const v = entry?.[key as BrandingOverrideKey];
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}
|
||||
return config[key]?.source === 'admin';
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
|
||||
if (!selectedHost) return domainEntries;
|
||||
const next = domainEntries.slice();
|
||||
const idx = next.findIndex(e => e.host === selectedHost);
|
||||
const base: DomainBrandingEntry =
|
||||
idx === -1 ? { host: selectedHost } : { ...next[idx] };
|
||||
const writable = base as unknown as Record<string, string | undefined>;
|
||||
for (const [key, value] of Object.entries(merge)) {
|
||||
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
writable[key] = value;
|
||||
} else {
|
||||
delete writable[key];
|
||||
}
|
||||
}
|
||||
if (idx === -1) next.push(base);
|
||||
else next[idx] = base;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const payload = selectedHost
|
||||
? { domainBranding: buildUpdatedDomainBranding(edits) }
|
||||
: edits;
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: selectedHost
|
||||
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
|
||||
: 'Branding updated. Changes visible on next page load.',
|
||||
});
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
@@ -78,12 +175,20 @@ export function BrandingTab() {
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
if (selectedHost) formData.append('host', selectedHost);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
@@ -98,10 +203,9 @@ export function BrandingTab() {
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
}));
|
||||
// Refresh from server so domainBranding entries reflect the upload.
|
||||
await fetchConfig();
|
||||
void data;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
@@ -112,10 +216,13 @@ export function BrandingTab() {
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const body: { slot: string; host?: string } = { slot };
|
||||
if (selectedHost) body.host = selectedHost;
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -133,6 +240,25 @@ export function BrandingTab() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
if (selectedHost) {
|
||||
// Domain scope: drop the field from the entry and PATCH the array.
|
||||
const updated = buildUpdatedDomainBranding({ [key]: '' });
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: updated }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Default scope: revert via DELETE /api/admin/config
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -148,12 +274,71 @@ export function BrandingTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
async function handleAddDomain() {
|
||||
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
|
||||
if (!host) {
|
||||
setNewHostError('Enter a hostname');
|
||||
return;
|
||||
}
|
||||
if (!HOST_RE.test(host)) {
|
||||
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
|
||||
return;
|
||||
}
|
||||
if (domainEntries.some(e => e.host === host)) {
|
||||
setNewHostError('A branding entry for this host already exists');
|
||||
return;
|
||||
}
|
||||
setNewHostError(null);
|
||||
|
||||
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewHostInput('');
|
||||
setAddingHost(false);
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setNewHostError(data.error || 'Failed to add domain');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDomain() {
|
||||
if (!selectedHost) return;
|
||||
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
|
||||
return;
|
||||
}
|
||||
const next = domainEntries.filter(e => e.host !== selectedHost);
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleScopeChange(host: string | null) {
|
||||
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
@@ -178,6 +363,102 @@ export function BrandingTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope picker */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Scope</h2>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(null)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{domainEntries.map(entry => (
|
||||
<button
|
||||
key={entry.host}
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(entry.host)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === entry.host
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{entry.host}
|
||||
</button>
|
||||
))}
|
||||
{!addingHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(true); setNewHostError(null); }}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add domain
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addingHost && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newHostInput}
|
||||
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
|
||||
placeholder="mail.example.com or *.example.com"
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddDomain}
|
||||
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||
Unset fields fall back to the Default values.
|
||||
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDomain}
|
||||
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Remove domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
@@ -195,9 +476,9 @@ export function BrandingTab() {
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,7 +487,7 @@ export function BrandingTab() {
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
@@ -222,9 +503,9 @@ export function BrandingTab() {
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
@@ -237,7 +518,7 @@ export function BrandingTab() {
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -249,7 +530,7 @@ export function BrandingTab() {
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
@@ -262,6 +543,146 @@ export function BrandingTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{PWA_IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{PWA_TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
@@ -271,8 +692,10 @@ export function BrandingTab() {
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
@@ -283,7 +706,7 @@ export function BrandingTab() {
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
@@ -74,6 +74,18 @@ export function PolicyTab() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setPushRelayUrl(value: string) {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function togglePushRelayLocked() {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleLocked(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
@@ -183,6 +195,34 @@ export function PolicyTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={policy.pushRelayUrl ?? ''}
|
||||
onChange={(e) => setPushRelayUrl(e.target.value)}
|
||||
placeholder="https://notifications.relay.example.com"
|
||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!policy.pushRelayUrlLocked}
|
||||
onChange={togglePushRelayLocked}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
|
||||
@@ -125,6 +125,7 @@ export function SettingsTab() {
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } 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 -
|
||||
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
+29
-6
@@ -1,12 +1,25 @@
|
||||
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 { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||
// next-intl's getLocale() returns the default locale here - emitting
|
||||
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
|
||||
// "translate this page". Recover the active locale from the request pathname
|
||||
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
|
||||
// Accept-Language) when the path carries no locale segment.
|
||||
async function resolveRequestLocale(): Promise<string> {
|
||||
const pathname = (await headers()).get("x-pathname") || "";
|
||||
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
|
||||
return seg ?? (await getLocale());
|
||||
}
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
@@ -26,10 +39,21 @@ export const viewport: Viewport = {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// Localize the <head> description to match the UI language; a hardcoded
|
||||
// English description is another signal that makes Chrome offer to
|
||||
// "translate this page". Resolve the locale from the request path, since this
|
||||
// layout is above the [locale] segment (see resolveRequestLocale).
|
||||
const locale = await resolveRequestLocale();
|
||||
const t = await getTranslations({ locale });
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
description: t("meta_description"),
|
||||
// A private webmail should not be indexed by search engines. This is opt-in
|
||||
// via Settings -> General; the default (false) emits noindex/nofollow.
|
||||
robots: configManager.get<boolean>("searchEngineIndexing", false)
|
||||
? { index: true, follow: true }
|
||||
: { index: false, follow: false },
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
@@ -38,7 +62,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
icons: { icon: faviconUrl },
|
||||
icons: { icon: withBasePath(faviconUrl) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +71,7 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const locale = await resolveRequestLocale();
|
||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
@@ -91,7 +115,6 @@ export default async function RootLayout({
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function SetupWizardPage() {
|
||||
// edge cases that swallow client-side replaces after the
|
||||
// setupComplete flag flips.
|
||||
setTimeout(() => {
|
||||
window.location.assign('/admin/login');
|
||||
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||
}, 1500);
|
||||
}}
|
||||
/>
|
||||
@@ -340,13 +340,13 @@ function CompletedScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -422,13 +422,13 @@ function AlreadyConfiguredScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -1329,7 +1329,7 @@ function BrandingAsset({
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
||||
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '../globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||
// so the sandbox never fetches same-origin assets itself.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
||||
>
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+160
-32
@@ -3,8 +3,13 @@ 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 {
|
||||
parseDomainBranding,
|
||||
type DomainBrandingEntry,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -21,26 +26,99 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
type UploadSlot = BrandingOverrideKey;
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
const VALID_SLOTS = new Set<UploadSlot>([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
|
||||
|
||||
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
|
||||
// because we'd need a real subdomain to serve the file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip directory traversal, keep only safe chars
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function normalizeHost(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Filename used to store a per-host uploaded asset. */
|
||||
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
|
||||
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
|
||||
}
|
||||
|
||||
/** True if the file belongs to the given host+slot (any extension). */
|
||||
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
|
||||
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
|
||||
return filename.startsWith(prefix);
|
||||
}
|
||||
|
||||
/** Merge a per-host update into the existing domainBranding array. */
|
||||
function mergeDomainEntry(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
patch: Partial<DomainBrandingEntry>,
|
||||
): DomainBrandingEntry[] {
|
||||
const next = current.slice();
|
||||
const idx = next.findIndex(e => e.host === host);
|
||||
if (idx === -1) {
|
||||
next.push({ host, ...patch });
|
||||
} else {
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove keys from a host's entry. If the entry has nothing left besides
|
||||
* `host`, drop it entirely. */
|
||||
function clearDomainKeys(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
keys: BrandingOverrideKey[],
|
||||
): DomainBrandingEntry[] {
|
||||
const idx = current.findIndex(e => e.host === host);
|
||||
if (idx === -1) return current;
|
||||
const entry = { ...current[idx] };
|
||||
for (const key of keys) delete (entry as Record<string, unknown>)[key];
|
||||
const next = current.slice();
|
||||
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
|
||||
next.splice(idx, 1);
|
||||
} else {
|
||||
next[idx] = entry;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||
* - host (optional): when set, the upload is stored against the
|
||||
* per-domain entry for that hostname instead of the global default.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -51,15 +129,24 @@ export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const slot = formData.get('slot') as string | null;
|
||||
const rawHost = (formData.get('host') as string | null) ?? '';
|
||||
|
||||
if (!file || !slot) {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
@@ -71,34 +158,51 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine extension from mime type
|
||||
const extMap: 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',
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||
const safeName = host
|
||||
? domainAssetName(host, slot as BrandingOverrideKey, ext)
|
||||
: sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
// Strip any prior asset for the same slot but a different extension so
|
||||
// the directory doesn't accumulate orphan files on re-upload.
|
||||
const dir = getBrandingDir();
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (f === safeName) continue;
|
||||
const isSame = host
|
||||
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
|
||||
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
|
||||
if (isSame) {
|
||||
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Update config to point to the served URL
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
}
|
||||
|
||||
await auditLog('branding_upload', {
|
||||
slot,
|
||||
host: host || undefined,
|
||||
filename: safeName,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
}, ip);
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
@@ -110,7 +214,11 @@ export async function POST(request: NextRequest) {
|
||||
/**
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
* Expects JSON body: { slot: string, host?: string }
|
||||
*
|
||||
* When `host` is provided, only the per-domain asset for that host+slot is
|
||||
* removed (and the override in `domainBranding[host][slot]` is cleared).
|
||||
* Otherwise the global asset and config override are removed.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
@@ -118,28 +226,48 @@ export async function DELETE(request: NextRequest) {
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slot } = await request.json();
|
||||
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
|
||||
const slot = body.slot;
|
||||
const rawHost = body.host ?? '';
|
||||
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find and remove matching files for this slot
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getBrandingDir();
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
if (host) {
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
|
||||
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ext of POSSIBLE_EXTS) {
|
||||
const filePath = path.join(dir, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the config override so it falls back to default/env
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.removeAdminOverride(slot);
|
||||
}
|
||||
|
||||
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
@@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) {
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Normalize domainBranding: drop entries with an invalid/missing host or
|
||||
// duplicate hosts before persisting. Each entry's branding field strings
|
||||
// are passed through unchanged (URL/string content is the operator's
|
||||
// responsibility, same as the flat branding fields).
|
||||
if ('domainBranding' in updates) {
|
||||
const incoming = updates.domainBranding;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseDomainBranding(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.domainBranding = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
|
||||
@@ -192,6 +192,9 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
|
||||
/**
|
||||
* Same-origin OAuth metadata (discovery) proxy.
|
||||
*
|
||||
* The login page needs the authorization_endpoint to build the PKCE authorize
|
||||
* URL in the browser. Discovering it directly from the browser means a
|
||||
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
|
||||
* to CORS: providers like Authentik serve those documents without an
|
||||
* Access-Control-Allow-Origin header, so the browser blocks the response and
|
||||
* discovery fails (issue #382). Performing discovery here - server to server,
|
||||
* where CORS does not apply - and handing the result back as a same-origin
|
||||
* response sidesteps the problem entirely.
|
||||
*
|
||||
* The discovery URL is resolved from admin config (via server_id), never from
|
||||
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
|
||||
* in the discovered document are still gated by the SSRF validator inside
|
||||
* discoverOAuth. The returned fields are public well-known metadata.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
await configManager.ensureLoaded();
|
||||
const serverId = request.nextUrl.searchParams.get('server_id');
|
||||
|
||||
let discoveryUrl: string;
|
||||
try {
|
||||
({ discoveryUrl } = getRequiredConfig(serverId));
|
||||
} catch {
|
||||
// OAuth not configured for this server - surface as "no metadata" rather
|
||||
// than a 500 so the login page just hides the SSO button.
|
||||
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await getMetadata(serverId);
|
||||
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
|
||||
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(metadata, {
|
||||
// Mirror the in-process discovery cache TTL so repeated login-page loads
|
||||
// hit the CDN/browser cache instead of re-running discovery.
|
||||
headers: { 'Cache-Control': 'private, max-age=600' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('OAuth metadata discovery error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -52,9 +53,13 @@ async function tryTokenRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
@@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
|
||||
+66
-36
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
* post-build configuration for Docker deployments.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 4. Default values
|
||||
* 1. Per-domain branding override (admin-configured, matched on request host)
|
||||
* 2. Admin dashboard overrides (data/admin/config.json)
|
||||
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 5. Default values
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
logger.debug('Config requested');
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const host = pickRequestHost(request);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
|
||||
// Per-domain override wins over the global value, but only when the
|
||||
// entry explicitly sets that key. Otherwise we fall through to the
|
||||
// global admin/env/default chain.
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === 'string' && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||
|
||||
return NextResponse.json({
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
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'),
|
||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
},
|
||||
{
|
||||
// Branding varies by host, so any cache between us and the browser
|
||||
// must key its entry by the host headers we consulted.
|
||||
headers: { Vary: 'Host, X-Forwarded-Host' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
*/
|
||||
|
||||
const ACCOUNT_ID = 'dev-account-001';
|
||||
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
|
||||
const emailCreationIds = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mailboxes
|
||||
@@ -721,7 +723,18 @@ const emails: MockEmail[] = [
|
||||
// Identities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITIES = [
|
||||
type MockIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: Array<{ name?: string; email: string }> | null;
|
||||
bcc: Array<{ name?: string; email: string }> | null;
|
||||
textSignature: string | null;
|
||||
htmlSignature: string | null;
|
||||
mayDelete: boolean;
|
||||
};
|
||||
|
||||
const IDENTITIES: MockIdentity[] = [
|
||||
{
|
||||
id: 'identity-001',
|
||||
name: 'Dev User',
|
||||
@@ -1533,6 +1546,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
bodyValues: {},
|
||||
};
|
||||
emails.unshift(newEmail);
|
||||
emailCreationIds.set(key, newId);
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
@@ -1559,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
const create = args.create as Record<string, unknown> | undefined;
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
if (create) {
|
||||
for (const key of Object.keys(create)) {
|
||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
||||
for (const [key, data] of Object.entries(create)) {
|
||||
const newId = `identity-${Date.now()}-${key}`;
|
||||
IDENTITIES.push({
|
||||
id: newId,
|
||||
name: (data.name as string) || '',
|
||||
email: (data.email as string) || '',
|
||||
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
|
||||
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
|
||||
textSignature: (data.textSignature as string | null) ?? null,
|
||||
htmlSignature: (data.htmlSignature as string | null) ?? null,
|
||||
mayDelete: true,
|
||||
});
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
|
||||
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
if (update) {
|
||||
for (const [id, changes] of Object.entries(update)) {
|
||||
const identity = IDENTITIES.find((i) => i.id === id);
|
||||
if (identity) {
|
||||
// Email is immutable per the identity form, so it's never in `changes`.
|
||||
if (changes.name !== undefined) identity.name = changes.name as string;
|
||||
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
|
||||
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
|
||||
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
|
||||
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = args.destroy as string[] | undefined;
|
||||
if (destroy) {
|
||||
for (const id of destroy) {
|
||||
const idx = IDENTITIES.findIndex((i) => i.id === id);
|
||||
if (idx !== -1) {
|
||||
IDENTITIES.splice(idx, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
|
||||
}
|
||||
|
||||
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1575,8 +1631,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
|
||||
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string; sendAt?: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
|
||||
if (create) {
|
||||
for (const [key, value] of Object.entries(create)) {
|
||||
const id = `submission-${Date.now()}-${key}`;
|
||||
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
|
||||
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
|
||||
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
|
||||
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
|
||||
? Date.now() + holdForSeconds * 1000
|
||||
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
|
||||
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
|
||||
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
|
||||
if (delayedUntil && value.emailId && value.identityId) {
|
||||
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
|
||||
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
|
||||
if (update) {
|
||||
for (const [id, patch] of Object.entries(update)) {
|
||||
const submission = scheduledSubmissions.find(s => s.id === id);
|
||||
if (submission && patch.undoStatus) {
|
||||
submission.undoStatus = patch.undoStatus;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
|
||||
const position = Number(args.position || 0);
|
||||
const limit = Number(args.limit || 50);
|
||||
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
|
||||
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1639,6 +1738,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
|
||||
'Identity/get': handleIdentityGet,
|
||||
'Identity/set': handleIdentitySet,
|
||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||
'Quota/get': handleQuotaGet,
|
||||
'VacationResponse/get': handleVacationResponseGet,
|
||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
||||
@@ -1776,7 +1877,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:quota': {},
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
|
||||
+17
-12
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
|
||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||
|
||||
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
|
||||
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
|
||||
// without spamming the DevTools console with red 404 errors. Avatar.tsx
|
||||
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
|
||||
const TRANSPARENT_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MISSING_FAVICON_HEADERS = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400', // 1 day
|
||||
'X-Bulwark-Favicon': 'missing',
|
||||
};
|
||||
|
||||
// Strict domain validation to prevent SSRF
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
|
||||
// Check negative cache (domains known to have no favicon)
|
||||
const neg = negativeCache.get(normalizedDomain);
|
||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Check cache
|
||||
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!upstream.ok) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
|
||||
if (data.byteLength < 10) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
|
||||
@@ -57,6 +57,9 @@ export async function GET() {
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||
// strings via api.i18n.t().
|
||||
locales: p.locales,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
|
||||
@@ -3,11 +3,18 @@ import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
@@ -17,13 +24,21 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
@@ -34,8 +49,15 @@ export async function GET(
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const iconUrl =
|
||||
domainOverrides.pwaIconUrl ||
|
||||
domainOverrides.faviconUrl ||
|
||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||
if (!iconUrl) {
|
||||
@@ -45,11 +67,14 @@ export async function GET(
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
@@ -61,7 +86,7 @@ export async function GET(
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Variant → target output size + admin config key.
|
||||
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
|
||||
* the slot the manifest tells the browser about.
|
||||
*/
|
||||
const VARIANTS = {
|
||||
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
|
||||
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
|
||||
} as const;
|
||||
|
||||
type Variant = keyof typeof VARIANTS;
|
||||
|
||||
// Cache resized images keyed by (variant, source URL).
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ variant: string }> },
|
||||
) {
|
||||
const { variant: variantParam } = await params;
|
||||
if (!(variantParam in VARIANTS)) {
|
||||
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
|
||||
}
|
||||
const { width, height, configKey } = VARIANTS[variantParam as Variant];
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const sourceEntry = sources[configKey];
|
||||
const screenshotUrl =
|
||||
domainOverrides[configKey] ||
|
||||
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
|
||||
if (!screenshotUrl) {
|
||||
return new NextResponse('No PWA screenshot configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
const cacheKey = `${variantParam}|${screenshotUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(screenshotUrl);
|
||||
// 'cover' fills the target box without letterboxing - screenshots benefit
|
||||
// more from cropping than from a transparent frame around them. Users get
|
||||
// a hint about the recommended aspect ratio in the admin UI.
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(width, height, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA screenshot:', err);
|
||||
return new NextResponse('Failed to generate screenshot', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
||||
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
||||
// provider } here via api.http.post; we forward to a free translation backend
|
||||
// and return { translatedText, detectedSource } in a stable shape.
|
||||
//
|
||||
// Two providers:
|
||||
// - "mymemory" — public MyMemory API, no configuration required. Its
|
||||
// langpair needs an explicit source language, so when the
|
||||
// plugin asks for "auto" we detect it locally first.
|
||||
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
|
||||
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
|
||||
// source auto-detection.
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
// Long bodies are split into ~480-char chunks for MyMemory and translated
|
||||
// sequentially, so allow enough headroom for ~10 round-trips.
|
||||
const TIMEOUT_MS = 25000;
|
||||
|
||||
type Provider = 'mymemory' | 'libretranslate';
|
||||
|
||||
interface TranslateBody {
|
||||
text?: unknown;
|
||||
target?: unknown;
|
||||
source?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
|
||||
interface TranslateResult {
|
||||
translatedText: string;
|
||||
detectedSource?: string;
|
||||
}
|
||||
|
||||
// ─── Lightweight language detection ───────────────────────────
|
||||
//
|
||||
// MyMemory has no auto-detect, so we infer a source language from the text.
|
||||
// Non-Latin scripts are decided by Unicode range; Latin-script European
|
||||
// languages are scored by stop-word frequency. Detection only needs to be good
|
||||
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
|
||||
// already in the target language.
|
||||
|
||||
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
|
||||
[/[-ヿ]/, 'ja'], // Hiragana / Katakana
|
||||
[/[가-]/, 'ko'], // Hangul
|
||||
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
|
||||
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
|
||||
[/[Ͱ-Ͽ]/, 'el'], // Greek
|
||||
[/[-ۿ]/, 'ar'], // Arabic
|
||||
[/[-]/, 'he'], // Hebrew
|
||||
[/[ऀ-ॿ]/, 'hi'], // Devanagari
|
||||
];
|
||||
|
||||
// Distinctive stop words per Latin-script language from the manifest's option
|
||||
// list. Kept small and high-signal to avoid cross-language collisions.
|
||||
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
|
||||
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
|
||||
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
|
||||
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
|
||||
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
|
||||
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
|
||||
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
|
||||
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
|
||||
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
|
||||
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
|
||||
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
|
||||
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
|
||||
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
|
||||
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
|
||||
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
|
||||
};
|
||||
|
||||
function detectLanguage(text: string): string {
|
||||
const sample = text.slice(0, 1000);
|
||||
for (const [range, lang] of SCRIPT_RANGES) {
|
||||
if (range.test(sample)) {
|
||||
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
|
||||
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
|
||||
if (words.length === 0) return 'en';
|
||||
const counts: Record<string, number> = {};
|
||||
const wordSet = new Set(words);
|
||||
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
|
||||
let score = 0;
|
||||
for (const stop of stops) if (wordSet.has(stop)) score += 1;
|
||||
counts[lang] = score;
|
||||
}
|
||||
let best = 'en';
|
||||
let bestScore = -1;
|
||||
for (const [lang, score] of Object.entries(counts)) {
|
||||
if (score > bestScore) {
|
||||
best = lang;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestScore > 0 ? best : 'en';
|
||||
}
|
||||
|
||||
function baseLang(code: string): string {
|
||||
return String(code || '').toLowerCase().split('-')[0];
|
||||
}
|
||||
|
||||
// ─── Chunking ─────────────────────────────────────────────────
|
||||
//
|
||||
// MyMemory's free endpoint caps each request's `q` at 500 characters and
|
||||
// silently returns only the translated prefix beyond that — which is why long
|
||||
// emails came back truncated ("…about Sel…"). We split the text into
|
||||
// line-aware chunks under the limit, translate each, then rejoin so the whole
|
||||
// body is covered.
|
||||
|
||||
const MYMEMORY_CHUNK = 480;
|
||||
|
||||
function chunkText(text: string, max: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let cur = '';
|
||||
const flush = () => {
|
||||
if (cur) {
|
||||
chunks.push(cur);
|
||||
cur = '';
|
||||
}
|
||||
};
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.length > max) {
|
||||
flush();
|
||||
// A single over-long line (e.g. a long URL list): split on words, and
|
||||
// hard-cut any word that is itself longer than the limit.
|
||||
let seg = '';
|
||||
for (const word of line.split(' ')) {
|
||||
const piece = seg ? seg + ' ' + word : word;
|
||||
if (piece.length > max) {
|
||||
if (seg) {
|
||||
chunks.push(seg);
|
||||
seg = '';
|
||||
}
|
||||
if (word.length > max) {
|
||||
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
|
||||
} else {
|
||||
seg = word;
|
||||
}
|
||||
} else {
|
||||
seg = piece;
|
||||
}
|
||||
}
|
||||
if (seg) chunks.push(seg);
|
||||
continue;
|
||||
}
|
||||
if (cur && cur.length + 1 + line.length > max) flush();
|
||||
cur = cur ? cur + '\n' + line : line;
|
||||
}
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Providers ────────────────────────────────────────────────
|
||||
|
||||
async function mymemoryRequest(
|
||||
q: string,
|
||||
langpair: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
const url = new URL('https://api.mymemory.translated.net/get');
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('langpair', langpair);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(`MyMemory returned ${res.status}`);
|
||||
}
|
||||
const status = Number(data.responseStatus);
|
||||
if (status && status !== 200) {
|
||||
throw new Error(data.responseDetails || `MyMemory error ${status}`);
|
||||
}
|
||||
const translatedText = data.responseData?.translatedText || '';
|
||||
if (!translatedText) {
|
||||
throw new Error('MyMemory returned no translation');
|
||||
}
|
||||
return translatedText;
|
||||
}
|
||||
|
||||
async function translateMyMemory(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
|
||||
const tgt = baseLang(target);
|
||||
// Nothing to do if already in the target language; the plugin skips display.
|
||||
if (detected === tgt) {
|
||||
return { translatedText: text, detectedSource: detected };
|
||||
}
|
||||
const langpair = `${detected}|${tgt}`;
|
||||
const chunks = chunkText(text, MYMEMORY_CHUNK);
|
||||
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
|
||||
// usually one or two chunks.
|
||||
const translated: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
translated.push(await mymemoryRequest(chunk, langpair, signal));
|
||||
}
|
||||
return { translatedText: translated.join('\n'), detectedSource: detected };
|
||||
}
|
||||
|
||||
async function translateLibre(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const endpoint = process.env.LIBRETRANSLATE_URL;
|
||||
if (!endpoint) {
|
||||
throw new Error('LibreTranslate is not configured on this server');
|
||||
}
|
||||
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
|
||||
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
q: text,
|
||||
source: source || 'auto',
|
||||
target: baseLang(target),
|
||||
format: 'text',
|
||||
...(apiKey ? { api_key: apiKey } : {}),
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
|
||||
}
|
||||
if (!data.translatedText) {
|
||||
throw new Error(data.error || 'LibreTranslate returned no translation');
|
||||
}
|
||||
return {
|
||||
translatedText: data.translatedText,
|
||||
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Route ────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: TranslateBody;
|
||||
try {
|
||||
body = (await request.json()) as TranslateBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
||||
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
||||
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const result =
|
||||
provider === 'libretranslate'
|
||||
? await translateLibre(text, source, target, controller.signal)
|
||||
: await translateMyMemory(text, source, target, controller.signal);
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Translation failed';
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -70,7 +70,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-border: #262626;
|
||||
--color-border: rgba(128, 128, 128, 0.3);
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-background: #0a0a0a;
|
||||
@@ -242,8 +242,8 @@ body {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-text {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-14
@@ -1,5 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
);
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === "string" && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
configManager.get<string>("appName") ||
|
||||
branded<string>("appName", "") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = configManager.get<string>("appShortName") || appName;
|
||||
const shortName = branded<string>("appShortName", "") || appName;
|
||||
const description =
|
||||
configManager.get<string>("appDescription") ||
|
||||
branded<string>("appDescription", "") ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
||||
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
||||
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
|
||||
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
|
||||
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
||||
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
||||
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
|
||||
// are the built-in placeholder paths and not real custom icons.
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
|
||||
// env var, or per-domain override), serve dynamically resized PNGs via
|
||||
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
|
||||
// sources marked "default" are the built-in placeholder paths and not
|
||||
// real custom icons.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const hasCustomIcon =
|
||||
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
|
||||
!!domainOverrides.pwaIconUrl ||
|
||||
!!domainOverrides.faviconUrl ||
|
||||
sources.pwaIconUrl?.source !== "default" ||
|
||||
sources.faviconUrl?.source !== "default";
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
@@ -73,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||
// otherwise fall back to the built-in Bulwark screenshots from public/.
|
||||
screenshots: (() => {
|
||||
const hasMobile =
|
||||
!!domainOverrides.pwaScreenshotMobileUrl ||
|
||||
sources.pwaScreenshotMobileUrl?.source !== "default";
|
||||
const hasDesktop =
|
||||
!!domainOverrides.pwaScreenshotDesktopUrl ||
|
||||
sources.pwaScreenshotDesktopUrl?.source !== "default";
|
||||
return [
|
||||
hasMobile
|
||||
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
hasDesktop
|
||||
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
];
|
||||
})(),
|
||||
protocol_handlers: [
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
onShareCalendar?: (calendar: Calendar) => void;
|
||||
onCreateEvent?: (calendar: Calendar) => void;
|
||||
onClearCalendar?: (calendar: Calendar) => void;
|
||||
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onResetColor,
|
||||
onShareCalendar,
|
||||
onCreateEvent,
|
||||
onClearCalendar,
|
||||
@@ -94,6 +97,7 @@ export function CalendarSidebarPanel({
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||
@@ -303,9 +307,11 @@ export function CalendarSidebarPanel({
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canChangeColor = !!onColorChange;
|
||||
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
const canResetColor = !!onResetColor && hasColorOverride;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
@@ -335,6 +341,13 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{canResetColor && (
|
||||
<ContextMenuItem
|
||||
icon={Shuffle}
|
||||
label={tMgmt('random_color')}
|
||||
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
|
||||
}
|
||||
|
||||
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
||||
// A local color override on a shared calendar wins over per-event colors,
|
||||
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
|
||||
if (calendar?.colorIsLocalOverride && calendar.color) {
|
||||
return sanitizeColor(calendar.color);
|
||||
}
|
||||
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
||||
}
|
||||
|
||||
@@ -78,7 +83,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = getEventEndDate(event);
|
||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||
const safeFormat = (d: Date, fmt: string) => {
|
||||
if (isNaN(d.getTime())) return "--:--";
|
||||
try { return format(d, fmt); } catch { return "--:--"; }
|
||||
};
|
||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
@@ -153,7 +162,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesBefore && "-ml-0.5",
|
||||
continuesAfter && "pr-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -143,6 +143,8 @@ export function EventDetailPopover({
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = getEventEndDate(event);
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
@@ -331,16 +333,50 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
{isMultiDay ? (
|
||||
event.showWithoutTime ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)} –
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(displayEndDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(startDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(endDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -621,14 +621,42 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium">{formatEventDate(displayEnd)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
@@ -742,17 +770,55 @@ export function EventModal({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durMin)})
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,11 +5,14 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -23,6 +26,7 @@ import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builde
|
||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
@@ -35,6 +39,10 @@ import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
@@ -84,7 +92,10 @@ interface EmailComposerProps {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
onSaveState?: (data: ComposerDraftData) => void;
|
||||
@@ -164,8 +175,21 @@ function buildEmbeddedSignatureHtml(
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatLocalDateTimeInput(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function getDefaultScheduleValue(): string {
|
||||
const tomorrowAtEight = new Date();
|
||||
tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1);
|
||||
tomorrowAtEight.setHours(8, 0, 0, 0);
|
||||
return formatLocalDateTimeInput(tomorrowAtEight);
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
onClose,
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
@@ -177,14 +201,17 @@ export function EmailComposer({
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
// Pro shell: surface identities from every connected account, grouped
|
||||
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
||||
@@ -208,10 +235,16 @@ export function EmailComposer({
|
||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||
? initialCurrentIdentityForSig
|
||||
: primaryIdentity;
|
||||
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote' &&
|
||||
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
hasInitialSignature;
|
||||
// New-mail composes always embed the signature into the editor body so it's
|
||||
// editable/removable (the previous read-only preview below the editor was
|
||||
// never spec-correct - see #329). Compose mode also ignores any leftover
|
||||
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -239,11 +272,9 @@ export function EmailComposer({
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
const fwdPrefix = t('prefix.forward');
|
||||
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
|
||||
return buildForwardSubject(replyTo.subject, t('prefix.forward'));
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
const rePrefix = t('prefix.reply');
|
||||
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
return buildReplySubject(replyTo.subject, t('prefix.reply'));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -252,11 +283,26 @@ export function EmailComposer({
|
||||
if (plainTextMode) {
|
||||
// Plain text mode: produce plain text body with no HTML
|
||||
const prefix = initialDraftText || "";
|
||||
// Compose mode: ignore any leftover replyTo (e.g. a selected mail in the
|
||||
// viewer) and embed the signature directly into the body so it's
|
||||
// editable. Fixes #329 (A,B).
|
||||
if (mode === 'compose') {
|
||||
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`;
|
||||
}
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward "From:" shows the full sender incl. address; reply line keeps
|
||||
// the bare name (reads naturally in the localized "On … wrote:" line).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||
@@ -277,19 +323,36 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||
// Compose mode: ignore any leftover replyTo and embed the signature
|
||||
// directly into the body so the user can edit/delete it. The leading
|
||||
// empty paragraph gives the cursor a place to land above the signature.
|
||||
if (mode === 'compose') {
|
||||
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||
const composePrefix = prefix || '<p></p>';
|
||||
const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
return `${composePrefix}${embedded}`;
|
||||
}
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
@@ -298,31 +361,46 @@ export function EmailComposer({
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
if (replyTo.htmlBody) {
|
||||
// Layout-heavy original: embed verbatim as a QuotedHtml island so
|
||||
// nested tables / MJML survive 1:1 (sanitize strips scripts/styles
|
||||
// first; cid rewrite runs after so its data-cid markers survive).
|
||||
const island = buildQuotedHtmlBlock(
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
|
||||
}
|
||||
const escaped = replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '';
|
||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||
const originalHtml = replyTo.htmlBody
|
||||
?? (replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '');
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
||||
: originalHtml;
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
|
||||
: escaped;
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// 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}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||
// its layout survives the editor round-trip. Sanitize first to strip
|
||||
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||
// aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false.
|
||||
const island = buildQuotedHtmlBlock(
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div>${island}`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
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}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -335,6 +413,7 @@ export function EmailComposer({
|
||||
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
|
||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||
@@ -375,6 +454,7 @@ export function EmailComposer({
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||
const [smimeSign_, setSmimeSign] = useState(false);
|
||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
@@ -382,6 +462,12 @@ export function EmailComposer({
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
const [scheduleValue, setScheduleValue] = useState('');
|
||||
const [scheduleError, setScheduleError] = useState('');
|
||||
const [showSendMenu, setShowSendMenu] = useState(false);
|
||||
const sendMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -441,10 +527,15 @@ export function EmailComposer({
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
// Replies/forwards only embed when configured for "above quote". Compose
|
||||
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
|
||||
if (!isReplyLike && mode !== 'compose') return;
|
||||
if (isReplyLike && signaturePosition !== 'above_quote') return;
|
||||
|
||||
const currentHtml = editor.getHTML();
|
||||
// serializeEditorContent (not getHTML) so a QuotedHtml island's verbatim
|
||||
// body isn't lost during the signature splice + setContent round-trip.
|
||||
const currentHtml = serializeEditorContent(editor);
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||
if (!startEl) return;
|
||||
@@ -466,15 +557,23 @@ export function EmailComposer({
|
||||
if (!parent) return;
|
||||
|
||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
||||
// from startEl to the next blockquote if no end marker is present.
|
||||
// from startEl to the next quote boundary if no end marker is present.
|
||||
// The quote boundary is either a legacy <blockquote> or the QuotedHtml
|
||||
// island wrapper (<div data-quoted-html>) - stop before either so the
|
||||
// signature splice never eats into the quoted body.
|
||||
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
|
||||
const isQuoteBoundary = (n: Node | null): boolean => {
|
||||
if (!n || n.nodeType !== 1) return false;
|
||||
const el = n as Element;
|
||||
return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html');
|
||||
};
|
||||
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;
|
||||
if (!removeUntil && isQuoteBoundary(next)) break;
|
||||
cursor = next;
|
||||
}
|
||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||
@@ -487,6 +586,23 @@ export function EmailComposer({
|
||||
}
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||
setShowSendMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutsideSendMenu);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu);
|
||||
}, []);
|
||||
|
||||
const openScheduleDialog = useCallback(() => {
|
||||
setScheduleError('');
|
||||
setScheduleValue(getDefaultScheduleValue());
|
||||
setShowScheduleDialog(true);
|
||||
setShowSendMenu(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
@@ -534,6 +650,76 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
// Hydrate inline images referenced by the quoted body (issue #163).
|
||||
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
|
||||
// data-cid; here we (1) register each inline attachment in inlineImagesRef
|
||||
// so the send path re-attaches the blob with the right cid, and (2) fetch
|
||||
// each blob as a data URL and swap it into the body so the editor actually
|
||||
// shows the image instead of a blank placeholder.
|
||||
useEffect(() => {
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (!composerClient || !replyTo?.attachments?.length) return;
|
||||
|
||||
const inlineAtts = replyTo.attachments.filter((att) =>
|
||||
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
|
||||
);
|
||||
if (inlineAtts.length === 0) return;
|
||||
|
||||
// Seed the ref synchronously so a fast Send still attaches the right blobs
|
||||
// even if the FileReader work below hasn't resolved yet.
|
||||
for (const att of inlineAtts) {
|
||||
if (!att.cid) continue;
|
||||
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
|
||||
inlineImagesRef.current.push({
|
||||
cid: att.cid,
|
||||
blobId: att.blobId,
|
||||
type: att.type,
|
||||
name: att.name || 'inline',
|
||||
size: att.size,
|
||||
dataUrl: '',
|
||||
});
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const updates = new Map<string, string>();
|
||||
for (const att of inlineAtts) {
|
||||
if (!att.cid) continue;
|
||||
try {
|
||||
const buffer = await composerClient.fetchBlobArrayBuffer(
|
||||
att.blobId,
|
||||
att.name || 'inline',
|
||||
att.type,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const blob = new Blob([buffer], { type: att.type });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
if (cancelled) return;
|
||||
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
|
||||
if (entry) entry.dataUrl = dataUrl;
|
||||
updates.set(att.cid, dataUrl);
|
||||
} catch (err) {
|
||||
debug.error('Failed to load inline image for compose', err);
|
||||
}
|
||||
}
|
||||
if (cancelled || updates.size === 0) return;
|
||||
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// We deliberately hydrate once per composer open - subsequent replyTo
|
||||
// object identity churn from parent renders shouldn't refetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [composerClient, plainTextMode, mode]);
|
||||
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
@@ -586,9 +772,16 @@ export function EmailComposer({
|
||||
// Ref to latest saveDraft for use in event handlers with stale closures
|
||||
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Set by the explicit close paths (clean close, save-and-close, discard) so
|
||||
// the unmount auto-save below doesn't fire and stash a stale pendingDraft
|
||||
// on the parent (#329 D). Without this, "Reply → Discard → New mail" would
|
||||
// open the next composer with the discarded reply's mode/replyTo.
|
||||
const explicitCloseRef = useRef(false);
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
if (onSaveState && isDirtyRef.current) {
|
||||
const s = stateRef.current;
|
||||
onSaveState({
|
||||
@@ -933,6 +1126,43 @@ export function EmailComposer({
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Inline preview for composer attachments, reusing the message viewer's
|
||||
// FilePreviewModal (so previewability and the open-in-new-tab safety gate are
|
||||
// handled there). Prefer the local File - no network - and fall back to the
|
||||
// uploaded blob (forwarded attachments, which carry only a blobId).
|
||||
const getPreviewAttachmentContent = useCallback(async () => {
|
||||
if (!previewAttachment) throw new Error('No attachment selected');
|
||||
if (previewAttachment.file) {
|
||||
return {
|
||||
blob: previewAttachment.file,
|
||||
contentType: previewAttachment.type || previewAttachment.file.type || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
if (composerClient && previewAttachment.blobId) {
|
||||
const blob = await composerClient.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream' };
|
||||
}
|
||||
throw new Error('No attachment content available');
|
||||
}, [previewAttachment, composerClient]);
|
||||
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
if (!previewAttachment) return;
|
||||
if (previewAttachment.file) {
|
||||
const url = URL.createObjectURL(previewAttachment.file);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = previewAttachment.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (composerClient && previewAttachment.blobId) {
|
||||
await composerClient.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
}
|
||||
}, [previewAttachment, composerClient]);
|
||||
|
||||
// Auto-save draft functionality
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
@@ -1106,6 +1336,33 @@ export function EmailComposer({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateScheduleValue = (value: string): string | null => {
|
||||
if (!value) return t('schedule_send_required');
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return t('schedule_send_invalid');
|
||||
if (time <= Date.now()) return t('schedule_send_future');
|
||||
if (composerClient) {
|
||||
const maxDelayedSend = composerClient.getMaxDelayedSend();
|
||||
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
|
||||
return t('schedule_send_too_late');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise<string | undefined> => {
|
||||
if (requestedDelayedUntil) return requestedDelayedUntil;
|
||||
if (sendDelaySeconds === 0) return undefined;
|
||||
if (composerClient?.hasDelayedSend()) {
|
||||
return new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
|
||||
}
|
||||
const confirmed = window.confirm(t('send_delay_unsupported_confirm'));
|
||||
if (!confirmed) {
|
||||
throw new Error(t('send_delay_unsupported'));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||
// references so recipient clients that strip data URIs can still render them.
|
||||
const rewriteInlineImages = (html: string): {
|
||||
@@ -1149,7 +1406,7 @@ export function EmailComposer({
|
||||
};
|
||||
};
|
||||
|
||||
const handleSend = async (skipAttachmentCheck = false) => {
|
||||
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
|
||||
@@ -1177,6 +1434,7 @@ export function EmailComposer({
|
||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
setAttachmentWarningKeyword(matched);
|
||||
setAttachmentWarningDelayedUntil(delayedUntil);
|
||||
setShowAttachmentWarning(true);
|
||||
return;
|
||||
}
|
||||
@@ -1227,12 +1485,14 @@ export function EmailComposer({
|
||||
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.
|
||||
// The signature is embedded into the body during init for compose mode
|
||||
// (when the initial identity had a signature) and for above-quote
|
||||
// replies/forwards - skip the trailing append in those cases so we don't
|
||||
// duplicate it.
|
||||
const signatureAlreadyInBody =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote';
|
||||
shouldEmbedSignatureInNewMail ||
|
||||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote');
|
||||
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
@@ -1265,6 +1525,7 @@ export function EmailComposer({
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
|
||||
try {
|
||||
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
|
||||
// Let plugins veto the send (external-mail warning, mistyped-domain
|
||||
// guards, etc.). Returning false from any handler aborts before either
|
||||
// the S/MIME or standard JMAP path runs.
|
||||
@@ -1414,7 +1675,16 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
// 7. Send via raw email path
|
||||
await sendRawEmail(client, payload, currentIdentity.id);
|
||||
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]);
|
||||
if (effectiveDelayedUntil && finalDraftId) {
|
||||
client.deleteEmail(finalDraftId).catch(err => {
|
||||
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||
toast.warning(t('schedule_send_cleanup_warning'));
|
||||
});
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await onScheduledSendCreated?.();
|
||||
}
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
@@ -1464,6 +1734,8 @@ export function EmailComposer({
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
requestReadReceipt,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
});
|
||||
|
||||
if (mode === 'reply' || mode === 'replyAll') {
|
||||
@@ -1488,15 +1760,56 @@ export function EmailComposer({
|
||||
setDraftId(null);
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
setShowScheduleDialog(false);
|
||||
setScheduleValue('');
|
||||
setScheduleError('');
|
||||
// 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, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(t('send_failed'));
|
||||
toast.error(err instanceof Error ? err.message : t('send_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleSend = () => {
|
||||
if (!composerClient?.hasDelayedSend()) {
|
||||
setScheduleError(t('schedule_send_unsupported'));
|
||||
return;
|
||||
}
|
||||
const error = validateScheduleValue(scheduleValue);
|
||||
if (error) {
|
||||
setScheduleError(error);
|
||||
return;
|
||||
}
|
||||
handleSend(false, new Date(scheduleValue).toISOString());
|
||||
};
|
||||
|
||||
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
|
||||
// draft. Scoped to events whose target lives inside this composer's
|
||||
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
||||
// once (inactive tabs are CSS-hidden, not unmounted), so a window
|
||||
// listener would otherwise fire every mounted composer's handleSend
|
||||
// on a single keystroke. handleSend is rebound every render, so we
|
||||
// route through a ref to keep the listener stable.
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleSendRef = useRef<((skipAttachmentCheck?: boolean) => Promise<void>) | undefined>(undefined);
|
||||
handleSendRef.current = handleSend;
|
||||
useEffect(() => {
|
||||
const handleSendShortcut = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.altKey || e.shiftKey) return;
|
||||
const root = composerRootRef.current;
|
||||
if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
void handleSendRef.current?.();
|
||||
};
|
||||
window.addEventListener('keydown', handleSendShortcut);
|
||||
return () => window.removeEventListener('keydown', handleSendShortcut);
|
||||
}, []);
|
||||
|
||||
const cleanClose = () => {
|
||||
explicitCloseRef.current = true;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
@@ -1505,6 +1818,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleSaveDraftAndClose = async () => {
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1515,6 +1829,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleDiscardAndClose = () => {
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1534,8 +1849,50 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
|
||||
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
|
||||
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
|
||||
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
|
||||
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return;
|
||||
|
||||
if (
|
||||
showTemplatePicker ||
|
||||
showSaveAsTemplate ||
|
||||
showScheduleDialog ||
|
||||
smimePassphrasePrompt ||
|
||||
showAttachmentWarning ||
|
||||
showCloseDialog
|
||||
) return;
|
||||
|
||||
if (isPlainEscape) {
|
||||
if (activeAutoField) return;
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSendShortcut) {
|
||||
if (e.repeat) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isScheduleShortcut) {
|
||||
e.preventDefault();
|
||||
if (!e.repeat && composerClient?.hasDelayedSend()) openScheduleDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full bg-background", className)}>
|
||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||
@@ -1548,6 +1905,7 @@ export function EmailComposer({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDraggingOver && (
|
||||
@@ -1868,8 +2226,9 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
embedded into the body (compose, or above-quote replies). */}
|
||||
{(shouldEmbedSignatureInNewMail
|
||||
|| ((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">
|
||||
@@ -1888,7 +2247,21 @@ export function EmailComposer({
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4 py-2 border-t shrink-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
|
||||
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => {
|
||||
// Clickable to preview only once it has content (local File or an
|
||||
// uploaded blob) and the type is previewable; never mid-upload.
|
||||
const canPreview = !att.uploading && !att.error
|
||||
&& (!!att.file || !!att.blobId)
|
||||
&& isFilePreviewable(att.name, att.type);
|
||||
const label = (
|
||||
<>
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
({formatFileSize(att.size)})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
@@ -1910,10 +2283,18 @@ export function EmailComposer({
|
||||
) : (
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
({formatFileSize(att.size)})
|
||||
</span>
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewAttachment(att)}
|
||||
title={att.name}
|
||||
className="flex items-center gap-2 min-w-0 hover:underline"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">{label}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||
@@ -1923,7 +2304,8 @@ export function EmailComposer({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{attachments.length > 3 && (
|
||||
<button
|
||||
onClick={() => setShowAllAttachments(prev => !prev)}
|
||||
@@ -1937,7 +2319,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<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)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]">
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
@@ -1975,7 +2357,6 @@ export function EmailComposer({
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* S/MIME toggles */}
|
||||
{canSmimeSign && (
|
||||
<>
|
||||
@@ -2001,6 +2382,21 @@ export function EmailComposer({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRequestReadReceipt(v => !v)}
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
requestReadReceipt && "bg-green-600 text-white hover:bg-green-600 hover:text-white dark:bg-green-600 dark:hover:bg-green-600"
|
||||
)}
|
||||
title={requestReadReceipt ? t('read_receipt_on') : t('read_receipt_off')}
|
||||
aria-pressed={requestReadReceipt}
|
||||
>
|
||||
<MailCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
@@ -2013,15 +2409,56 @@ export function EmailComposer({
|
||||
>
|
||||
{t('discard')}
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="rounded-r-none border-r border-primary-foreground/20"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend}
|
||||
title={t('schedule_send')}
|
||||
className="rounded-l-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
{showSendMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2060,6 +2497,29 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScheduleDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduleValue}
|
||||
onChange={(e) => {
|
||||
setScheduleValue(e.target.value);
|
||||
setScheduleError('');
|
||||
}}
|
||||
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
|
||||
/>
|
||||
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
|
||||
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S/MIME passphrase prompt */}
|
||||
{smimePassphrasePrompt && (
|
||||
<div
|
||||
@@ -2136,7 +2596,7 @@ export function EmailComposer({
|
||||
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||
{t('forgot_attachment.back')}
|
||||
</Button>
|
||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
|
||||
{t('forgot_attachment.send_anyway')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2175,6 +2635,15 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewAttachment && (
|
||||
<FilePreviewModal
|
||||
name={previewAttachment.name}
|
||||
onClose={() => setPreviewAttachment(null)}
|
||||
onDownload={handlePreviewAttachmentDownload}
|
||||
getFileContent={getPreviewAttachmentContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
@@ -2383,6 +2852,11 @@ function RecipientChipInput({
|
||||
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
|
||||
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
aria-invalid={validationError || undefined}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore
|
||||
data-op-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
</div>
|
||||
{validationError && validationMessage && (
|
||||
@@ -2399,4 +2873,4 @@ function RecipientChipInput({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: () => void;
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -133,6 +138,9 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
@@ -143,6 +151,8 @@ export function EmailContextMenu({
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -196,8 +206,36 @@ export function EmailContextMenu({
|
||||
</ContextMenuHeader>
|
||||
)}
|
||||
|
||||
{isScheduled && !showBatchActions && canCancelScheduled && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={CalendarClock}
|
||||
label={t("reschedule_send")}
|
||||
onClick={() => handleAction(onRescheduleScheduled!)}
|
||||
disabled={!onRescheduleScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={XCircle}
|
||||
label={t("cancel_scheduled_send")}
|
||||
onClick={() => handleAction(onCancelScheduled!)}
|
||||
disabled={!onCancelScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
|
||||
onClick={() => handleAction(onCancelScheduledForEdit!)}
|
||||
disabled={!onCancelScheduledForEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCancelScheduled && <ContextMenuSeparator />}
|
||||
|
||||
{!isScheduled && (
|
||||
<>
|
||||
|
||||
{/* Edit Draft - only for single draft emails */}
|
||||
{!showBatchActions && isDraft && onEditDraft && (
|
||||
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
@@ -209,7 +247,7 @@ export function EmailContextMenu({
|
||||
)}
|
||||
|
||||
{/* Single email actions - Reply, Reply All, Forward */}
|
||||
{!showBatchActions && (
|
||||
{!isScheduled && !showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={Reply}
|
||||
@@ -375,6 +413,8 @@ export function EmailContextMenu({
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</ContextMenu>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||
import { isAuthenticationSpoofed } from '@/lib/email-headers';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
interface EmailIdentityBadgeProps {
|
||||
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
|
||||
// Parse the from address to check for sub-addressing
|
||||
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||
|
||||
// Find matching identity (email sent BY the user)
|
||||
const matchingIdentity = identities.find(
|
||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||
);
|
||||
// Find matching identity (email sent BY the user). When the message is
|
||||
// likely spoofed, the From address can't be trusted, so we ignore any
|
||||
// identity match — otherwise a forged From matching one of the user's own
|
||||
// addresses would render a misleading "via <identity>" badge that implies
|
||||
// legitimacy.
|
||||
const spoofed = isAuthenticationSpoofed(email.authenticationResults);
|
||||
const matchingIdentity = spoofed
|
||||
? undefined
|
||||
: identities.find(
|
||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||
);
|
||||
|
||||
// Check if email was sent TO a sub-address (received email)
|
||||
let receivedToTag: string | null = null;
|
||||
|
||||
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
|
||||
@@ -4,13 +4,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -26,6 +27,8 @@ interface EmailListProps {
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
hasMore?: boolean;
|
||||
isLoadingMoreItems?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
@@ -39,6 +42,11 @@ interface EmailListProps {
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
onEditDraft?: (email: Email) => void;
|
||||
isScheduledView?: boolean;
|
||||
onLoadMoreScheduled?: () => void;
|
||||
onCancelScheduled?: (email: Email) => void | Promise<void>;
|
||||
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
||||
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
@@ -48,6 +56,8 @@ export function EmailList({
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
hasMore,
|
||||
isLoadingMoreItems,
|
||||
onOpenConversation,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
@@ -61,6 +71,11 @@ export function EmailList({
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
onEditDraft,
|
||||
isScheduledView = false,
|
||||
onLoadMoreScheduled,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailListProps) {
|
||||
const t = useTranslations('email_list');
|
||||
const { client } = useAuthStore();
|
||||
@@ -95,9 +110,9 @@ export function EmailList({
|
||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails, disableThreading);
|
||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails, disableThreading]);
|
||||
}, [emails, disableThreading, isScheduledView]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -107,7 +122,11 @@ export function EmailList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
@@ -216,10 +235,14 @@ export function EmailList({
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isScheduledView) {
|
||||
onLoadMoreScheduled?.();
|
||||
return;
|
||||
}
|
||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||
loadMoreEmails(client);
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||
|
||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadId);
|
||||
@@ -279,7 +302,7 @@ export function EmailList({
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
@@ -403,17 +426,19 @@ export function EmailList({
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
{isScheduledView ? (
|
||||
<CalendarClock className="w-10 h-10 text-muted-foreground" />
|
||||
) : searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||
) : (
|
||||
<MailX className="w-10 h-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-muted-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -465,13 +490,13 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
<div className="py-4 flex justify-center">
|
||||
{isLoadingMore && hasMoreEmails && (
|
||||
{footerIsLoadingMore && footerHasMore && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t('loading_more')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMoreEmails && emails.length > 0 && (
|
||||
{!footerHasMore && emails.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
||||
{t('no_more_emails')}
|
||||
</div>
|
||||
@@ -506,6 +531,9 @@ export function EmailList({
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
@@ -63,6 +66,7 @@ import {
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -73,12 +77,16 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { ReadReceiptBanner } from "./read-receipt-banner";
|
||||
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
@@ -121,6 +129,9 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: (delayedUntil: string) => void;
|
||||
onCompose?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
@@ -798,6 +809,7 @@ interface DraggableAttachmentChipProps {
|
||||
attachment: EffectiveAttachment;
|
||||
client: IJMAPClient | null;
|
||||
enabled: boolean;
|
||||
downloadName?: string;
|
||||
children: (dragProps: {
|
||||
draggable: boolean;
|
||||
onPointerEnter: () => void;
|
||||
@@ -806,9 +818,9 @@ interface DraggableAttachmentChipProps {
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
const source = useMemo<AttachmentDragSource>(() => ({
|
||||
name: attachment.name || 'download',
|
||||
name: downloadName || attachment.name || 'download',
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
getBlobUrl: async () => {
|
||||
if (attachment.blobId && client) {
|
||||
@@ -831,7 +843,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}), [attachment, client]);
|
||||
}), [attachment, client, downloadName]);
|
||||
const drag = useAttachmentDrag(source, enabled);
|
||||
return <>{children(drag)}</>;
|
||||
}
|
||||
@@ -869,6 +881,9 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
onCompose,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
@@ -878,6 +893,7 @@ export function EmailViewer({
|
||||
className,
|
||||
}: EmailViewerProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const tSmime = useTranslations('smime');
|
||||
@@ -897,9 +913,30 @@ export function EmailViewer({
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
|
||||
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
|
||||
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
|
||||
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
|
||||
const emailFilenameOptions = useMemo(() => ({
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const attachmentFilenameOptions = useMemo(() => ({
|
||||
template: attachmentDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -908,6 +945,8 @@ export function EmailViewer({
|
||||
|
||||
// Detect if the email is a draft
|
||||
const isDraft = email?.keywords?.['$draft'] === true;
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Color options for email tags (from user-defined keyword settings)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -920,8 +959,33 @@ export function EmailViewer({
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('reschedule_prompt'));
|
||||
if (!value) return null;
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
toast.error(tComposer('schedule_send_invalid'));
|
||||
return null;
|
||||
}
|
||||
if (time <= Date.now()) {
|
||||
toast.error(tComposer('schedule_send_future'));
|
||||
return null;
|
||||
}
|
||||
if (!client?.hasDelayedSend()) {
|
||||
toast.error(tComposer('schedule_send_unsupported'));
|
||||
return null;
|
||||
}
|
||||
const maxDelayedSend = client.getMaxDelayedSend();
|
||||
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
|
||||
toast.error(tComposer('schedule_send_too_late'));
|
||||
return null;
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}, [client, t, tComposer]);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||
@@ -2132,6 +2196,9 @@ export function EmailViewer({
|
||||
// Hide inline cid-referenced images when the user has opted to keep them
|
||||
// out of the attachment list (default on): these are embedded in the body.
|
||||
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
// Hide machine-readable report parts (MDN read-receipts, DSN bounce
|
||||
// reports). These are required MIME parts, not real user attachments.
|
||||
.filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status')
|
||||
.map((attachment, index) => ({
|
||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||
name: attachment.name || null,
|
||||
@@ -2559,6 +2626,15 @@ export function EmailViewer({
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
|
||||
const resolveAttachmentName = useCallback(
|
||||
(attachment: EffectiveAttachment) => {
|
||||
const fallback = attachment.name || 'download';
|
||||
if (!email) return fallback;
|
||||
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
|
||||
},
|
||||
[email, attachmentFilenameOptions],
|
||||
);
|
||||
|
||||
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
|
||||
@@ -2568,6 +2644,8 @@ export function EmailViewer({
|
||||
&& mailAttachmentAction === 'preview'
|
||||
&& isMimeTypeSafeForInlinePreview(attachment.type);
|
||||
|
||||
const downloadName = resolveAttachmentName(attachment);
|
||||
|
||||
const info: AttachmentInfo = {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
@@ -2578,7 +2656,7 @@ export function EmailViewer({
|
||||
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
||||
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2598,7 +2676,7 @@ export function EmailViewer({
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -2628,16 +2706,17 @@ export function EmailViewer({
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
|
||||
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
|
||||
|
||||
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
||||
const downloadName = resolveAttachmentName(attachment);
|
||||
const info: AttachmentInfo = {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
@@ -2647,7 +2726,7 @@ export function EmailViewer({
|
||||
};
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
||||
onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2660,7 +2739,7 @@ export function EmailViewer({
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -2676,12 +2755,12 @@ export function EmailViewer({
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [onDownloadAttachment, email?.id]);
|
||||
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
|
||||
|
||||
// Pre-fetch object URLs for image attachments so their actual contents can be
|
||||
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||
@@ -2797,7 +2876,9 @@ export function EmailViewer({
|
||||
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
||||
// @page margins (print-only), so they need a fallback body padding too.
|
||||
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
|
||||
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
|
||||
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
|
||||
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
|
||||
|
||||
// Word emails rely on empty <p class=MsoNormal> </p> spacers for vertical
|
||||
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||
@@ -2818,13 +2899,16 @@ export function EmailViewer({
|
||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
/* Force content height: some emails set html/body { height: 100% }, which -
|
||||
combined with overflow:hidden and our scrollHeight-based auto-resize -
|
||||
collapses the measured height and clips everything below the fold. */
|
||||
html, body { overflow: hidden; height: auto !important; }
|
||||
body { margin: 0; padding: ${bodyPadding}; 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; }
|
||||
@media (max-width: 640px) { body { padding-left: 0; padding-right: 0; } }
|
||||
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
|
||||
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; }
|
||||
td, th { overflow-wrap: anywhere; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
@@ -3054,35 +3138,58 @@ export function EmailViewer({
|
||||
const handleExportEmail = async () => {
|
||||
if (!email?.blobId || !client) return;
|
||||
try {
|
||||
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100);
|
||||
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
|
||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||
} catch {
|
||||
toast.error(tNotifications('export_email_error'));
|
||||
return;
|
||||
}
|
||||
const action = useSettingsStore.getState().postExportAction;
|
||||
if (action === 'archive') onArchive?.();
|
||||
else if (action === 'trash') onDelete?.();
|
||||
};
|
||||
|
||||
// Import email from .eml file
|
||||
// Import email from .eml file or .zip archive containing .eml files
|
||||
const handleImportEmail = () => {
|
||||
if (!client) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.eml,message/rfc822';
|
||||
input.accept = EML_IMPORT_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||
if (files.length === 0) return;
|
||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (!mailboxId) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let emails;
|
||||
try {
|
||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (!mailboxId) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
emails = await expandImportableEmails(files);
|
||||
} catch {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
for (const { blob } of emails) {
|
||||
try {
|
||||
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
|
||||
imported++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
|
||||
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
|
||||
}
|
||||
|
||||
if (imported > 0) {
|
||||
toast.success(tNotifications('import_email_success'));
|
||||
await fetchEmails(client);
|
||||
} catch {
|
||||
}
|
||||
if (failed > 0 || emails.length === 0) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
}
|
||||
};
|
||||
@@ -3154,6 +3261,103 @@ export function EmailViewer({
|
||||
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
|
||||
: false;
|
||||
|
||||
// ── Read receipt (MDN, RFC 8098) ──────────────────────────────
|
||||
// Detect a Disposition-Notification-To request on the open message. The
|
||||
// header is parsed into email.headers by the client; look it up
|
||||
// case-insensitively and extract the bare address.
|
||||
const readReceiptRequestedBy = useMemo(() => {
|
||||
const headers = email?.headers as Record<string, string | string[]> | undefined;
|
||||
if (!headers) return null;
|
||||
let raw: string | undefined;
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'disposition-notification-to') {
|
||||
const v = headers[key];
|
||||
raw = Array.isArray(v) ? v[0] : v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const m = raw.match(/<([^>]+)>/);
|
||||
const addr = (m ? m[1] : raw).trim();
|
||||
return addr || null;
|
||||
}, [email?.headers]);
|
||||
|
||||
// The identity whose address received the original message — the MDN is sent
|
||||
// "from" that address. Falls back to the primary identity.
|
||||
const receiptIdentity = useMemo(() => {
|
||||
if (!identities?.length) return null;
|
||||
const recipients = [...(email?.to || []), ...(email?.cc || [])]
|
||||
.map(r => r.email?.toLowerCase())
|
||||
.filter(Boolean);
|
||||
return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0];
|
||||
}, [identities, email?.to, email?.cc]);
|
||||
|
||||
const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true;
|
||||
const [mdnHandledLocally, setMdnHandledLocally] = useState(false);
|
||||
useEffect(() => { setMdnHandledLocally(false); }, [email?.id]);
|
||||
|
||||
// Only offer the receipt for mail you're actually reading in a "received"
|
||||
// location. Suppress your own copies (sent/drafts), discarded mail (trash),
|
||||
// and spam (junk) - never confirm your address to spammers. Inbox, Archive
|
||||
// and user folders all qualify.
|
||||
const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || '');
|
||||
|
||||
const shouldOfferReadReceipt =
|
||||
!!readReceiptRequestedBy &&
|
||||
!mdnAlreadyHandled &&
|
||||
!mdnHandledLocally &&
|
||||
readReceiptResponse !== 'never' &&
|
||||
inReceiptEligibleFolder &&
|
||||
!isDraft &&
|
||||
!!receiptIdentity;
|
||||
|
||||
const sendReadReceiptNow = useCallback(async (automatic: boolean) => {
|
||||
if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return;
|
||||
const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id);
|
||||
try {
|
||||
await client.sendReadReceipt({
|
||||
to: readReceiptRequestedBy,
|
||||
fromEmail: receiptIdentity.email,
|
||||
fromName: receiptIdentity.name,
|
||||
identityId: rawId ?? receiptIdentity.id,
|
||||
originalMessageId: email.messageId,
|
||||
originalSubject: email.subject,
|
||||
originalRecipient: receiptIdentity.email,
|
||||
automatic,
|
||||
subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }),
|
||||
humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }),
|
||||
});
|
||||
await client.setKeyword(email.id, '$mdnsent');
|
||||
} catch (err) {
|
||||
// Surface the failure instead of silently resetting the banner so we can
|
||||
// see which step (upload / import / submission) failed.
|
||||
console.error('Read-receipt (MDN) send failed:', err);
|
||||
toast.error(t('read_receipt.send_failed'), {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}, [client, email, readReceiptRequestedBy, receiptIdentity, t]);
|
||||
|
||||
const ignoreReadReceipt = useCallback(async () => {
|
||||
setMdnHandledLocally(true);
|
||||
if (client && email) {
|
||||
// $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the
|
||||
// request is suppressed everywhere - not just locally.
|
||||
try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ }
|
||||
}
|
||||
}, [client, email]);
|
||||
|
||||
// "always" mode: auto-send the MDN once when the message is opened.
|
||||
const autoMdnRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (readReceiptResponse !== 'always') return;
|
||||
if (!shouldOfferReadReceipt || !email) return;
|
||||
if (autoMdnRef.current === email.id) return;
|
||||
autoMdnRef.current = email.id;
|
||||
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
|
||||
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
if (isLoading && !email) {
|
||||
return (
|
||||
@@ -3206,9 +3410,9 @@ export function EmailViewer({
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = resolvedTheme === 'dark'
|
||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
@@ -3253,19 +3457,21 @@ export function EmailViewer({
|
||||
}
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
{!isEmbedded && (
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3291,7 +3497,32 @@ export function EmailViewer({
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{isDraft && onEditDraft && (
|
||||
{isScheduled && canCancelScheduled && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
|
||||
}}
|
||||
className="hidden sm:flex sm:h-8"
|
||||
title={t('reschedule_send')}
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isScheduled && isDraft && onEditDraft && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -3303,7 +3534,7 @@ export function EmailViewer({
|
||||
<span className="text-sm">{t('edit_draft')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isDraft && (<>
|
||||
{!isScheduled && !isDraft && (<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3345,6 +3576,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
||||
{!isScheduled && (
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
{/* Archive */}
|
||||
<Button
|
||||
@@ -3802,6 +4034,7 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3812,13 +4045,13 @@ export function EmailViewer({
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||
>
|
||||
{/* Mobile More menu sidebar overlay */}
|
||||
{isMobile && moreMenuOpen && (
|
||||
{!isScheduled && isMobile && moreMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
||||
onClick={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
{!isScheduled && isMobile && (
|
||||
<div className={cn(
|
||||
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
@@ -3993,8 +4226,8 @@ export function EmailViewer({
|
||||
"bg-background border-b border-border",
|
||||
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||
)}>
|
||||
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-2">
|
||||
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
<div className="px-2 sm:px-4 lg:px-6 h-14 flex items-center">
|
||||
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2 w-full">
|
||||
{renderToolbarItems(true)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -4205,7 +4438,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4285,7 +4518,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -4584,9 +4817,27 @@ export function EmailViewer({
|
||||
<section className="min-w-0">
|
||||
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{auth?.spf && (
|
||||
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
||||
)}
|
||||
{auth?.spf && (() => {
|
||||
// When multiple identities (HELO + MAIL FROM) were
|
||||
// evaluated, list each result in the tooltip for full
|
||||
// transparency; the chip itself shows the most severe.
|
||||
const breakdown = auth.spf.all && auth.spf.all.length > 1
|
||||
? auth.spf.all
|
||||
.map((r) => {
|
||||
const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF';
|
||||
return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`;
|
||||
})
|
||||
.join('\n')
|
||||
: null;
|
||||
return (
|
||||
<AuthChip
|
||||
name="SPF"
|
||||
result={auth.spf.result}
|
||||
extra={auth.spf.domain}
|
||||
tooltip={breakdown ? `${t('authentication.tooltip_spf')}\n\n${breakdown}` : t('authentication.tooltip_spf')}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{auth?.dkim && (
|
||||
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
||||
)}
|
||||
@@ -4750,10 +5001,44 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduled Banner */}
|
||||
{isScheduled && (
|
||||
<div className="border-b border-border bg-primary/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCancelScheduled && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
|
||||
}}
|
||||
>
|
||||
{t('reschedule_send')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
|
||||
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft Banner */}
|
||||
{isDraft && (
|
||||
<div className="border-b border-border bg-warning/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
|
||||
<div className="px-6 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-warning">
|
||||
<File className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
||||
@@ -4785,9 +5070,10 @@ export function EmailViewer({
|
||||
error={smimeUnlockError}
|
||||
/>
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation */}
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation) && (
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
@@ -4842,6 +5128,17 @@ export function EmailViewer({
|
||||
|
||||
|
||||
|
||||
{/* Read-receipt (MDN) request banner — only in "ask" mode */}
|
||||
{readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && (
|
||||
<div className="py-1">
|
||||
<ReadReceiptBanner
|
||||
requestedBy={readReceiptRequestedBy}
|
||||
onSend={() => sendReadReceiptNow(false)}
|
||||
onIgnore={ignoreReadReceipt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar Invitation Banner */}
|
||||
{hasCalendarInvitation && (
|
||||
<div className="py-1">
|
||||
@@ -4904,7 +5201,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4989,7 +5286,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -5047,7 +5344,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -5126,7 +5423,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -5222,14 +5519,16 @@ export function EmailViewer({
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
name={currentUserName || "You"}
|
||||
email={currentUserEmail || ""}
|
||||
name={activeAccount?.displayName || currentUserName || "You"}
|
||||
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
|
||||
size="lg"
|
||||
className="shadow-sm w-10 h-10"
|
||||
disableFavicon
|
||||
fallbackColor={activeAccount?.avatarColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
// Marker attribute that identifies the quoted-original wrapper in serialized
|
||||
// HTML, so parseHTML can recognise it on the way back in.
|
||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||
|
||||
/**
|
||||
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||
* and is NEVER parsed into the ProseMirror schema, so layout-heavy emails
|
||||
* (nested tables, MJML, Outlook divs) survive a reply/forward 1:1.
|
||||
*
|
||||
* Behaviour:
|
||||
* - To ProseMirror it's a single atomic block: Backspace at the boundary or
|
||||
* Ctrl+A + Delete removes the whole quote in one go ("wie es sich gehört").
|
||||
* - Its NodeView renders an inner `contentEditable` region so the user can
|
||||
* still redact text inside the quote. Inner edits are synced back into the
|
||||
* `html` attribute (without polluting the undo history).
|
||||
*
|
||||
* Serialization: ProseMirror's DOM serializer can't emit an atom's inner raw
|
||||
* HTML, so use `serializeEditorContent(editor)` (below) instead of
|
||||
* `editor.getHTML()` to read the composer body for sending/draft-saving.
|
||||
*/
|
||||
export const QuotedHtml = TiptapNode.create({
|
||||
name: "quotedHtml",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
html: {
|
||||
default: "",
|
||||
// Capture the verbatim inner HTML when parsing. Because the node is
|
||||
// an atom, ProseMirror does NOT descend into the children, so they
|
||||
// never hit the schema.
|
||||
parseHTML: (el) => el.innerHTML,
|
||||
// Not rendered as an attribute - the real content round-trips via the
|
||||
// custom serializer. renderHTML below only needs the wrapper.
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `div[${QUOTED_HTML_MARKER}]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// Only used for ProseMirror's internal/clipboard round-trip. The send /
|
||||
// draft path uses serializeEditorContent() which inlines attrs.html.
|
||||
return ["div", mergeAttributes(HTMLAttributes, { [QUOTED_HTML_MARKER]: "" })];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
// Host element ProseMirror manages. A subtle left border echoes the
|
||||
// classic email quote bar without touching the quoted content's styling.
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||
dom.className = "quoted-html-island";
|
||||
dom.style.cssText =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
|
||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||
// resets) would otherwise cascade INTO the quote and destroy its layout
|
||||
// - even though the verbatim HTML serializes/sends perfectly. Shadow DOM
|
||||
// isolates both directions: only the browser's UA defaults + the email's
|
||||
// own inline styles apply, so the in-editor rendering matches the sent
|
||||
// mail 1:1.
|
||||
const shadow = dom.attachShadow({ mode: "open" });
|
||||
const inner = document.createElement("div");
|
||||
inner.contentEditable = "true";
|
||||
inner.style.cssText = "outline:none;";
|
||||
inner.innerHTML = node.attrs.html || "";
|
||||
shadow.appendChild(inner);
|
||||
|
||||
// Track focus via focusin/focusout: inside a shadow root,
|
||||
// document.activeElement is retargeted to the host, so we can't rely on
|
||||
// it to detect "is the user editing in here".
|
||||
let focused = false;
|
||||
inner.addEventListener("focusin", () => {
|
||||
focused = true;
|
||||
});
|
||||
inner.addEventListener("focusout", () => {
|
||||
focused = false;
|
||||
});
|
||||
|
||||
// Sync inner edits back into the node attribute. Coalesced via rAF so a
|
||||
// burst of keystrokes is one transaction; addToHistory:false keeps
|
||||
// redaction edits out of the editor's undo stack. The `input` event is
|
||||
// composed and crosses the shadow boundary, so this listener fires.
|
||||
let frame = 0;
|
||||
const syncBack = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (typeof getPos !== "function") return;
|
||||
const pos = getPos();
|
||||
if (pos == null) return;
|
||||
const current = inner.innerHTML;
|
||||
if (current === node.attrs.html) return;
|
||||
editor.view.dispatch(
|
||||
editor.view.state.tr
|
||||
.setNodeAttribute(pos, "html", current)
|
||||
.setMeta("addToHistory", false)
|
||||
);
|
||||
});
|
||||
};
|
||||
inner.addEventListener("input", syncBack);
|
||||
|
||||
return {
|
||||
dom,
|
||||
// ProseMirror must not try to reconcile the foreign shadow content.
|
||||
ignoreMutation: () => true,
|
||||
// Events originating inside the island are retargeted to the host
|
||||
// (`dom`) once they cross the shadow boundary, so dom.contains(target)
|
||||
// is true for them → let the native shadow contentEditable handle
|
||||
// them. Events from the surrounding doc (boundary Backspace,
|
||||
// Ctrl+A+Delete) target other elements → fall through to ProseMirror
|
||||
// so whole-block deletion still works.
|
||||
stopEvent: (event) => {
|
||||
const target = event.target as Node | null;
|
||||
return !!target && dom.contains(target);
|
||||
},
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "quotedHtml") return false;
|
||||
// Don't clobber the caret while the user is redacting inside.
|
||||
if (!focused && inner.innerHTML !== updatedNode.attrs.html) {
|
||||
inner.innerHTML = updatedNode.attrs.html || "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
destroy: () => {
|
||||
cancelAnimationFrame(frame);
|
||||
inner.removeEventListener("input", syncBack);
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize the composer document to HTML for sending / draft-saving.
|
||||
*
|
||||
* Use this INSTEAD of `editor.getHTML()`: ProseMirror's DOM serializer cannot
|
||||
* emit the raw inner HTML of an atom node, so it would drop the quoted body.
|
||||
* Here we walk the top-level nodes, inline the quote node's verbatim `html`,
|
||||
* and serialize everything else normally.
|
||||
*/
|
||||
export function serializeEditorContent(editor: Editor): string {
|
||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||
const parts: string[] = [];
|
||||
editor.state.doc.forEach((node) => {
|
||||
if (node.type.name === "quotedHtml") {
|
||||
// Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft
|
||||
// round-trips: re-opening parses this back into a QuotedHtml node
|
||||
// instead of letting the schema mangle the raw table layout again.
|
||||
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
|
||||
return;
|
||||
}
|
||||
const fragment = serializer.serializeNode(node);
|
||||
const tmp = document.createElement("div");
|
||||
tmp.appendChild(fragment);
|
||||
parts.push(tmp.innerHTML);
|
||||
});
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the editor-content wrapper that the composer prepends/appends so the
|
||||
* quoted original becomes a single QuotedHtml node. The inner HTML must be
|
||||
* pre-sanitized (scripts/styles/head stripped, cid: images rewritten).
|
||||
*
|
||||
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
|
||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface ReadReceiptBannerProps {
|
||||
/** Address that requested the receipt (Disposition-Notification-To). */
|
||||
requestedBy: string;
|
||||
/** Sends the MDN. Should resolve when the receipt has been submitted. */
|
||||
onSend: () => Promise<void>;
|
||||
/** Suppresses the request without sending (sets $MDNSent server-side). */
|
||||
onIgnore: () => void;
|
||||
}
|
||||
|
||||
export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) {
|
||||
const t = useTranslations('email_viewer.read_receipt');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
if (state === 'sent') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{t('sent')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-foreground">{t('prompt')}</span>
|
||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setState('sending');
|
||||
try {
|
||||
await onSend();
|
||||
setState('sent');
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}}
|
||||
disabled={state === 'sending'}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{t('send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onIgnore}
|
||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{t('ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
parseHTML: (el) => {
|
||||
const dataCid = el.getAttribute("data-cid");
|
||||
if (dataCid) return dataCid;
|
||||
// Fall back to deriving the cid from `src="cid:xxx"` so inline
|
||||
// image refs survive editor round-trips even when data-cid was
|
||||
// never set (defensive — the composer normally pre-rewrites
|
||||
// quoted-body cid: refs into data-cid).
|
||||
const src = el.getAttribute("src") || "";
|
||||
if (/^cid:/i.test(src)) return src.slice(4) || null;
|
||||
return null;
|
||||
},
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Table } from "@tiptap/extension-table";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Bold,
|
||||
@@ -231,6 +232,9 @@ export function RichTextEditor({
|
||||
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
||||
},
|
||||
}),
|
||||
// Quoted/forwarded original email body - held verbatim as an atomic
|
||||
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
||||
QuotedHtml,
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
@@ -281,14 +285,18 @@ export function RichTextEditor({
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
// serializeEditorContent (not getHTML) so the verbatim quoted-original
|
||||
// HTML held in the QuotedHtml atom node is emitted intact.
|
||||
onChange(serializeEditorContent(editor));
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
// Sync external content changes (e.g. template application)
|
||||
// Sync external content changes (e.g. template application). Compare against
|
||||
// the custom serialization so a doc that only differs inside a QuotedHtml
|
||||
// island isn't needlessly re-parsed (which would reset the island DOM).
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
if (editor && content !== serializeEditorContent(editor)) {
|
||||
editor.commands.setContent(content, { emitUpdate: false });
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -67,15 +67,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
|
||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
@@ -91,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -374,12 +400,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
|
||||
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
@@ -645,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -715,14 +756,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -751,16 +802,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Paperclip, Download } from "lucide-react";
|
||||
import { sanitizeEmailHtmlForIframe } from "@/lib/email-sanitization";
|
||||
|
||||
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
|
||||
// this preview renders are typed.
|
||||
export type ParsedEml = {
|
||||
subject?: string;
|
||||
from?: { name?: string; address?: string };
|
||||
to?: Array<{ name?: string; address?: string }>;
|
||||
date?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments?: Array<{ filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }>;
|
||||
};
|
||||
|
||||
function formatAddress(a?: { name?: string; address?: string }): string {
|
||||
if (!a) return "";
|
||||
if (a.name && a.address) return `${a.name} <${a.address}>`;
|
||||
return a.address || a.name || "";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Renders a .eml attachment like an email: header (from/to/subject/date) + the
|
||||
// body, plus the message's own attachments. The body is sanitized with
|
||||
// DOMPurify AND rendered in a fully-locked sandbox iframe (sandbox="" - no
|
||||
// scripts, no same-origin), so a script-bearing .eml can never execute in our
|
||||
// origin. Parsing happens in the caller (FilePreviewModal); this is pure
|
||||
// presentation.
|
||||
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
|
||||
const bodyDoc = message.html
|
||||
? sanitizeEmailHtmlForIframe(message.html)
|
||||
: message.text
|
||||
? `<pre style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
|
||||
: "";
|
||||
|
||||
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[number]) => {
|
||||
if (!att.content) return;
|
||||
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
|
||||
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
|
||||
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = att.filename || "attachment";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-full self-start overflow-auto rounded-lg border border-border bg-background shadow-2xl p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{message.from && (
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||
)}
|
||||
{message.to && message.to.length > 0 && (
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
{bodyDoc && (
|
||||
<iframe
|
||||
title={message.subject || "email"}
|
||||
sandbox=""
|
||||
srcDoc={bodyDoc}
|
||||
className="w-full min-h-[55vh] mt-3 rounded bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => downloadAttachment(att)}
|
||||
title={t("download")}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
|
||||
>
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="max-w-[200px] truncate">{att.filename || "attachment"}</span>
|
||||
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
import { X, Download, Loader2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getFilePreviewKind } from "@/lib/file-preview";
|
||||
import { getFilePreviewKind, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
|
||||
|
||||
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
|
||||
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
|
||||
// desktop bundle.
|
||||
const PdfMobileViewer = dynamic(
|
||||
() => import("@/components/files/pdf-mobile-viewer").then((m) => m.PdfMobileViewer),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
// Map a few well-known extensions back to canonical MIME types. Used when the
|
||||
// server returns application/octet-stream (or empty) for an attachment whose
|
||||
// actual type is obvious from the filename. The blob.type drives how browsers
|
||||
// render blob: URLs, so guessing wrong here means the inline preview silently
|
||||
// downgrades to a download.
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
bmp: "image/bmp",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
ogv: "video/ogg",
|
||||
};
|
||||
|
||||
function inferMimeFromName(name: string): string | undefined {
|
||||
const ext = name.toLowerCase().split(".").pop();
|
||||
return ext ? EXT_TO_MIME[ext] : undefined;
|
||||
}
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
name: string;
|
||||
@@ -111,6 +149,54 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
|
||||
const [pdfInlineSupported, setPdfInlineSupported] = useState(true);
|
||||
// Whether the resolved blob MIME is inert enough to open as a top-level
|
||||
// navigation. Blob URLs inherit our origin, so opening a script-bearing type
|
||||
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
|
||||
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
|
||||
const [emlContent, setEmlContent] = useState<ParsedEml | null>(null);
|
||||
|
||||
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
||||
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
||||
// signal and correctly reports false on Android Chrome (no inline viewer).
|
||||
// iOS Safari is the exception: it reports true (it can show PDFs on top-frame
|
||||
// navigation) yet renders only the FIRST page inside an <iframe> - a
|
||||
// long-standing WebKit limitation - so it must use pdf.js too. Detect iOS
|
||||
// (incl. iPadOS, which spoofs a "Macintosh" UA but exposes touch points).
|
||||
useEffect(() => {
|
||||
const nav = navigator as Navigator & { pdfViewerEnabled?: boolean };
|
||||
const isIOS =
|
||||
/iPad|iPhone|iPod/.test(nav.userAgent) ||
|
||||
(nav.maxTouchPoints > 1 && /Macintosh/.test(nav.userAgent));
|
||||
if (isIOS) {
|
||||
setPdfInlineSupported(false);
|
||||
} else if (typeof nav.pdfViewerEnabled === "boolean") {
|
||||
setPdfInlineSupported(nav.pdfViewerEnabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep the latest onClose for the back-button handler without re-subscribing.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Make the Android/browser Back button close the preview instead of
|
||||
// navigating the page underneath: push a throwaway history entry when the
|
||||
// modal opens and close it on popstate. On a normal close (X / backdrop /
|
||||
// Escape) the modal unmounts and we pop that entry ourselves, so the user's
|
||||
// next Back isn't swallowed by it.
|
||||
useEffect(() => {
|
||||
window.history.pushState({ __filePreview: true }, "");
|
||||
let poppedByBack = false;
|
||||
const onPop = () => {
|
||||
poppedByBack = true;
|
||||
onCloseRef.current();
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPop);
|
||||
if (!poppedByBack) window.history.back();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fileType = resolvedFileType;
|
||||
|
||||
@@ -120,6 +206,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
|
||||
setContent(null);
|
||||
setObjectUrl(null);
|
||||
setCanOpenInNewTab(false);
|
||||
setEmlContent(null);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setResolvedFileType(getFilePreviewKind(name));
|
||||
@@ -136,9 +224,36 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
if (previewType === "text" || previewType === "markdown") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setContent(text);
|
||||
} else if (previewType === "eml") {
|
||||
// Parse the embedded message (postal-mime, dynamic-imported so it
|
||||
// stays off the bundle) and render it like an email via EmlPreview.
|
||||
const { default: PostalMime } = await import("postal-mime");
|
||||
const parsed = await new PostalMime().parse(await blob.arrayBuffer());
|
||||
if (!cancelled) setEmlContent(parsed as ParsedEml);
|
||||
} else {
|
||||
revokeUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setObjectUrl(revokeUrl);
|
||||
// Stalwart's download endpoint can return generic
|
||||
// application/octet-stream for attachments even when the email's
|
||||
// MIME structure declared application/pdf (etc.). The blob inherits
|
||||
// that, so a blob: URL plugged into <iframe> looks like a binary
|
||||
// stream and Chrome/Edge silently download it (with the blob UUID
|
||||
// as filename) instead of rendering inline. Re-wrap with the most
|
||||
// specific MIME we can resolve - prefer explicit attachment type,
|
||||
// then filename-derived MIME, then whatever the blob came with.
|
||||
const inferredFromName = inferMimeFromName(name);
|
||||
const isUseless = (ty?: string) =>
|
||||
!ty || ty === "application/octet-stream" || ty === "binary/octet-stream";
|
||||
const effectiveType =
|
||||
(contentType && !isUseless(contentType) ? contentType : undefined)
|
||||
?? inferredFromName
|
||||
?? blob.type;
|
||||
const typedBlob = blob.type !== effectiveType
|
||||
? new Blob([blob], { type: effectiveType })
|
||||
: blob;
|
||||
revokeUrl = URL.createObjectURL(typedBlob);
|
||||
if (!cancelled) {
|
||||
setObjectUrl(revokeUrl);
|
||||
setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
@@ -173,6 +288,18 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{objectUrl && canOpenInNewTab && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title={t("open_in_new_tab")}
|
||||
aria-label={t("open_in_new_tab")}
|
||||
onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -211,6 +338,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "eml" && emlContent && (
|
||||
<EmlPreview message={emlContent} />
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<img
|
||||
src={objectUrl}
|
||||
@@ -231,19 +362,29 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<object
|
||||
data={objectUrl}
|
||||
type="application/pdf"
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && (
|
||||
// <iframe> renders PDFs reliably across desktop Chromium, Firefox,
|
||||
// and Safari from a blob: URL. <object> was prone to falling back to
|
||||
// a silent download when the blob's Content-Type wasn't recognised.
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
aria-label={name}
|
||||
title={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && !pdfInlineSupported && (
|
||||
// Mobile browsers can't show a PDF inline in an <iframe> (Android: a
|
||||
// blank frame / silent download; iOS: only the first page), so render
|
||||
// it with pdf.js (canvas) instead. The header's open-in-new-tab /
|
||||
// download actions are the fallback if pdf.js can't render the doc.
|
||||
<div
|
||||
className="w-full max-w-3xl h-full overflow-auto rounded-lg bg-neutral-200 dark:bg-neutral-800 p-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
</object>
|
||||
<PdfMobileViewer url={objectUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "audio" && objectUrl && (
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have
|
||||
// no native inline PDF viewer, so the desktop <iframe src=blob:> approach
|
||||
// renders a blank frame. pdf.js rasterises each page to a <canvas> in pure JS,
|
||||
// so it works everywhere regardless of native PDF support.
|
||||
//
|
||||
// This component is dynamic-imported (ssr:false) only on the mobile PDF path,
|
||||
// and it dynamic-imports pdfjs-dist itself, so the (~hundreds of KB) library +
|
||||
// worker never reach the desktop bundle.
|
||||
|
||||
// iOS WebKit caps total canvas area (~16 MP) and is memory-sensitive; keep each
|
||||
// page's backing store well under that so large pages don't render blank. The
|
||||
// backing store is rendered at ~2x the fit width (dpr), which also gives the
|
||||
// double-tap zoom some headroom before CSS upscaling softens the text.
|
||||
const MAX_CANVAS_AREA = 4_000_000; // ~4 MP per page
|
||||
|
||||
// Double-tap zoom steps: fit -> 2x -> 3x -> fit. Implemented as the page
|
||||
// container's CSS width (so panning is native scrolling, not a transform).
|
||||
const ZOOM_STEPS = [1, 2, 3];
|
||||
const MAX_ZOOM = 4; // pinch can go a bit beyond the double-tap steps
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const DOUBLE_TAP_SLOP = 30; // px
|
||||
|
||||
export function PdfMobileViewer({ url }: { url: string }) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pagesRef = useRef<HTMLDivElement>(null);
|
||||
const zoom = useRef({ step: 0, scale: 1 });
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const t = useTranslations("files");
|
||||
|
||||
// Render the PDF pages to canvases.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadingTask: import("pdfjs-dist").PDFDocumentLoadingTask | null = null;
|
||||
|
||||
// Reset zoom for a freshly loaded document.
|
||||
zoom.current = { step: 0, scale: 1 };
|
||||
if (pagesRef.current) pagesRef.current.style.width = "100%";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
loadingTask = pdfjs.getDocument({ url });
|
||||
const doc = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
|
||||
const pages = pagesRef.current;
|
||||
if (!pages) return;
|
||||
pages.replaceChildren();
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const cssWidth = Math.min(pages.clientWidth || 320, 900);
|
||||
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (cancelled) return;
|
||||
const page = await doc.getPage(n);
|
||||
const baseVp = page.getViewport({ scale: 1 });
|
||||
let scale = (cssWidth / baseVp.width) * dpr;
|
||||
const area = baseVp.width * scale * (baseVp.height * scale);
|
||||
if (area > MAX_CANVAS_AREA) scale *= Math.sqrt(MAX_CANVAS_AREA / area);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "auto";
|
||||
canvas.style.display = "block";
|
||||
canvas.style.margin = "0 auto 8px";
|
||||
canvas.style.background = "#fff";
|
||||
pages.appendChild(canvas);
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
}
|
||||
if (!cancelled) setStatus("ready");
|
||||
} catch {
|
||||
if (!cancelled) setStatus("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void loadingTask?.destroy().catch(() => {});
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
// Gesture zoom. 1-finger double-tap cycles the steps (fit -> 2x -> 3x ->
|
||||
// fit); 2-finger pinch zooms continuously up to MAX_ZOOM. Both drive a
|
||||
// per-page CSS-width zoom that pans via native scrolling and re-centre on the
|
||||
// gesture point. touch-action (pan-x pan-y) keeps 1-finger panning native
|
||||
// while disabling the browser's own pinch/double-tap page zoom, which we
|
||||
// replace here.
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const distance = (a: Touch, b: Touch) =>
|
||||
Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||
|
||||
// Apply an absolute zoom scale, keeping (cx, cy) [relative to root] under
|
||||
// the same content point.
|
||||
const applyZoom = (target: number, cx: number, cy: number) => {
|
||||
const pages = pagesRef.current;
|
||||
if (!pages) return;
|
||||
const next = Math.max(1, Math.min(MAX_ZOOM, target));
|
||||
const ratio = next / zoom.current.scale;
|
||||
if (ratio === 1) return;
|
||||
pages.style.width = `${next * 100}%`;
|
||||
void root.offsetWidth; // force reflow so the new scroll range is live
|
||||
root.scrollLeft = (root.scrollLeft + cx) * ratio - cx;
|
||||
root.scrollTop = (root.scrollTop + cy) * ratio - cy;
|
||||
zoom.current.scale = next;
|
||||
};
|
||||
|
||||
let lastTap = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let pinching = false;
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartScale = 1;
|
||||
let didPinch = false;
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
pinching = true;
|
||||
didPinch = true;
|
||||
pinchStartDist = distance(e.touches[0], e.touches[1]) || 1;
|
||||
pinchStartScale = zoom.current.scale;
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (!pinching || e.touches.length !== 2) return;
|
||||
e.preventDefault(); // take over the 2-finger gesture from native pan
|
||||
const rect = root.getBoundingClientRect();
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
applyZoom(pinchStartScale * (distance(e.touches[0], e.touches[1]) / pinchStartDist), cx, cy);
|
||||
};
|
||||
|
||||
const onEnd = (e: TouchEvent) => {
|
||||
if (pinching && e.touches.length < 2) {
|
||||
pinching = false;
|
||||
// Snap the step index to the current scale so double-tap stays sensible.
|
||||
const s = zoom.current.scale;
|
||||
zoom.current.step = s <= 1.01 ? 0 : s < 3 ? 1 : 2;
|
||||
}
|
||||
if (e.touches.length > 0) return; // fingers still down
|
||||
if (didPinch) {
|
||||
didPinch = false; // a pinch is not a tap
|
||||
return;
|
||||
}
|
||||
if (e.changedTouches.length !== 1) return;
|
||||
const touch = e.changedTouches[0];
|
||||
const now = e.timeStamp;
|
||||
const isDouble =
|
||||
now - lastTap < DOUBLE_TAP_MS &&
|
||||
Math.abs(touch.clientX - lastX) < DOUBLE_TAP_SLOP &&
|
||||
Math.abs(touch.clientY - lastY) < DOUBLE_TAP_SLOP;
|
||||
if (!isDouble) {
|
||||
lastTap = now;
|
||||
lastX = touch.clientX;
|
||||
lastY = touch.clientY;
|
||||
return;
|
||||
}
|
||||
lastTap = 0; // consume so a third tap starts fresh
|
||||
e.preventDefault();
|
||||
const rect = root.getBoundingClientRect();
|
||||
zoom.current.step = (zoom.current.step + 1) % ZOOM_STEPS.length;
|
||||
applyZoom(ZOOM_STEPS[zoom.current.step], touch.clientX - rect.left, touch.clientY - rect.top);
|
||||
};
|
||||
|
||||
root.addEventListener("touchstart", onStart, { passive: true });
|
||||
root.addEventListener("touchmove", onMove, { passive: false });
|
||||
root.addEventListener("touchend", onEnd, { passive: false });
|
||||
return () => {
|
||||
root.removeEventListener("touchstart", onStart);
|
||||
root.removeEventListener("touchmove", onMove);
|
||||
root.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="w-full h-full overflow-auto"
|
||||
// Allow panning, but disable the browser's pinch/double-tap page zoom so
|
||||
// our own double-tap zoom drives the PDF instead of the whole modal.
|
||||
style={{ touchAction: "pan-x pan-y" }}
|
||||
>
|
||||
{status === "loading" && (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
// pdf.js couldn't render this document (corrupt/encrypted, worker load
|
||||
// failure, ...). Offer the OS/native viewer instead of a stuck frame.
|
||||
<div className="flex justify-center py-10">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t("open_in_new_tab")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={pagesRef} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
|
||||
}
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = [
|
||||
"from", "to", "cc", "subject", "header", "size", "body",
|
||||
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||
];
|
||||
|
||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
||||
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === "size") return SIZE_COMPARATORS;
|
||||
if (field === "attachment") return ATTACHMENT_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
||||
];
|
||||
@@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition {
|
||||
return { field: "from", comparator: "contains", value: "" };
|
||||
}
|
||||
|
||||
// Multi-value handling: conditions are stored as string | string[]. The UI
|
||||
// presents them as a single comma-separated text input — the user types
|
||||
// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay
|
||||
// strings so existing single-value rules don't change shape.
|
||||
function valueToInputString(v: string | string[]): string {
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
return v;
|
||||
}
|
||||
|
||||
function inputStringToValue(s: string): string | string[] {
|
||||
const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) return parts[0];
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isConditionValueEmpty(v: string | string[]): boolean {
|
||||
if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim());
|
||||
return !v.trim();
|
||||
}
|
||||
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
@@ -78,7 +107,10 @@ export function FilterRuleModal({
|
||||
const pathMap = new Map<string, string>();
|
||||
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
// Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
|
||||
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
|
||||
const segment = node.role === "inbox" ? "INBOX" : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) buildPaths(node.children, fullPath);
|
||||
}
|
||||
@@ -94,9 +126,23 @@ export function FilterRuleModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(
|
||||
(c) => c.value.trim()
|
||||
);
|
||||
// While editing, condition.value is always the raw string typed into the
|
||||
// input (commas not yet split). Convert to array form here on save so a
|
||||
// user typing "a, b, c" actually persists as ["a","b","c"]. This is the
|
||||
// moment we know editing is finished - splitting earlier would eat any
|
||||
// comma the user just typed mid-edit.
|
||||
const validConditions = conditions
|
||||
.filter((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return true;
|
||||
return !isConditionValueEmpty(c.value);
|
||||
})
|
||||
.map((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return c;
|
||||
if (c.field === "size") return c; // numeric, single-value only
|
||||
if (typeof c.value !== "string") return c; // already structured
|
||||
const parsed = inputStringToValue(c.value);
|
||||
return { ...c, value: parsed };
|
||||
});
|
||||
if (validConditions.length === 0) {
|
||||
toast.error(t("validation_empty_conditions"));
|
||||
return;
|
||||
@@ -126,15 +172,27 @@ export function FilterRuleModal({
|
||||
prev.map((c, i) => {
|
||||
if (i !== index) return c;
|
||||
const updated = { ...c, ...updates };
|
||||
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "greater_than";
|
||||
}
|
||||
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "contains";
|
||||
// Reconcile the comparator when the field changes so we never end up
|
||||
// with e.g. (field=attachment, comparator=contains) — invalid for the
|
||||
// Sieve generator. Each field has its own valid comparator set.
|
||||
if (updates.field && updates.field !== c.field) {
|
||||
const allowed = comparatorsFor(updates.field);
|
||||
if (!allowed.includes(c.comparator)) {
|
||||
updated.comparator = allowed[0];
|
||||
}
|
||||
}
|
||||
if (updates.field && updates.field !== "header") {
|
||||
delete updated.headerName;
|
||||
}
|
||||
// has_any takes no value; clear it so we don't leak old text into
|
||||
// the generated Sieve.
|
||||
if (updated.field === "attachment" && updated.comparator === "has_any") {
|
||||
updated.value = "";
|
||||
}
|
||||
// Size is numeric, single value only - collapse any list to scalar.
|
||||
if (updated.field === "size" && Array.isArray(updated.value)) {
|
||||
updated.value = updated.value[0] ?? "";
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
@@ -278,24 +336,58 @@ export function FilterRuleModal({
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
|
||||
(c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
placeholder={
|
||||
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
{/* has_any takes no value; render a stub so the row layout
|
||||
stays consistent but no input is editable. */}
|
||||
{condition.field === "attachment" && condition.comparator === "has_any" ? (
|
||||
<div className="flex-1 min-w-[120px]" />
|
||||
) : (
|
||||
<Input
|
||||
value={valueToInputString(condition.value)}
|
||||
onChange={(e) =>
|
||||
// Store the raw input string while typing. Splitting
|
||||
// commas into an array on every keystroke would eat
|
||||
// the comma the moment it's typed.
|
||||
updateCondition(index, { value: e.target.value })
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// On blur: normalise comma-separated input into an
|
||||
// array (or single string when only one item). Size
|
||||
// stays numeric/single-value; attachment-has_any has
|
||||
// no value at all.
|
||||
if (condition.field === "size") return;
|
||||
if (
|
||||
condition.field === "attachment" &&
|
||||
condition.comparator === "has_any"
|
||||
)
|
||||
return;
|
||||
const parsed = inputStringToValue(e.target.value);
|
||||
// Only update if the normalised shape actually
|
||||
// differs - avoids triggering a no-op re-render and
|
||||
// resetting the user's cursor on every blur.
|
||||
if (
|
||||
JSON.stringify(parsed) !== JSON.stringify(condition.value)
|
||||
) {
|
||||
updateCondition(index, { value: parsed });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
condition.field === "size"
|
||||
? t("size_placeholder")
|
||||
: condition.field === "attachment"
|
||||
? t("attachment_type_placeholder")
|
||||
: t("value_placeholder_multi")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
|
||||
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
@@ -385,7 +385,7 @@ export function NavigationRail({
|
||||
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className={cn(
|
||||
"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",
|
||||
@@ -444,7 +444,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
@@ -572,7 +572,7 @@ export function NavigationRail({
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
|
||||
@@ -77,9 +77,11 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
||||
onKeyDown={handleKeyDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={cn(
|
||||
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
||||
// Centered over the seam with negative margins so the handle adds no
|
||||
// net layout space (panes sit flush) while staying draggable.
|
||||
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group z-10",
|
||||
"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",
|
||||
isHorizontal ? "h-1 -my-0.5 cursor-row-resize bg-border" : "w-1 -mx-0.5 cursor-col-resize",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -74,6 +74,8 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||
@@ -674,6 +676,8 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
className,
|
||||
multiAccountMode = false,
|
||||
accountMailboxes,
|
||||
@@ -732,15 +736,20 @@ export function Sidebar({
|
||||
// pane.
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
||||
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
||||
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
|
||||
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||
// 2+ account requirement still applies - with a single account the
|
||||
// unified counts would just duplicate that account's inbox.
|
||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
|
||||
// single account we still surface unified when the user has opted into
|
||||
// merging group/shared inboxes — otherwise the counts would just duplicate
|
||||
// the one inbox.
|
||||
const showUnified =
|
||||
(multiAccountMode || enableUnifiedMailbox) &&
|
||||
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
@@ -895,11 +904,11 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
const openFolderSettings = () => {
|
||||
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
||||
try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ }
|
||||
router.push('/settings');
|
||||
};
|
||||
const openKeywordSettings = () => {
|
||||
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
||||
try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ }
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
@@ -933,30 +942,34 @@ export function Sidebar({
|
||||
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
||||
would otherwise render an empty strip (no collapse, no switcher). */}
|
||||
{!isEmbedded && (
|
||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSidebarClose}
|
||||
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
// Border lives on the wrapper (outside the h-14 box) so the bar's total
|
||||
// height matches the search/reply toolbars, which border-b their wrapper too.
|
||||
<div className="border-b border-border">
|
||||
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSidebarClose}
|
||||
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||
>
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||
>
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{!isCollapsed && !hideAccountSwitcher && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
{!isCollapsed && !hideAccountSwitcher && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1022,22 +1035,35 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
))}
|
||||
{isActive && showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1062,20 +1088,33 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))}
|
||||
{showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
// Lightweight **bold** support in plugin dialog messages. Everything else is
|
||||
// rendered literally (newlines come from the parent's white-space: pre-wrap).
|
||||
// Splitting on the ** delimiter yields alternating plain/bold segments (odd
|
||||
// indices are bold). Plugins control these strings, so the delimiters balance.
|
||||
function renderMessage(message?: string): React.ReactNode {
|
||||
if (!message) return null;
|
||||
return message.split('**').map((seg, i) =>
|
||||
i % 2 === 1
|
||||
? <strong key={i}>{seg}</strong>
|
||||
: <React.Fragment key={i}>{seg}</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'var(--color-popover, #fff)',
|
||||
color: 'var(--color-popover-foreground, #0f172a)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
||||
{current.message}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{renderMessage(current.message)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
||||
color: '#fff',
|
||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
|
||||
import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
@@ -69,6 +71,16 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
instanceRef.current?.updateProps(extraProps ?? {});
|
||||
}, [extraProps]);
|
||||
|
||||
// Re-theme the live slot iframe when the host theme changes (dark/light
|
||||
// toggle or custom theme switch), without tearing down the iframe. Reacting
|
||||
// to resolvedTheme + activeThemeId covers both; the snapshot reads the
|
||||
// resolved DOM values so it picks up whichever is active.
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
useEffect(() => {
|
||||
instanceRef.current?.setTheme(snapshotHostTheme());
|
||||
}, [resolvedTheme, activeThemeId]);
|
||||
|
||||
if (show !== true) return null;
|
||||
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
|
||||
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const isScheduledView = useEmailStore((s) => s.isScheduledView);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const tabIdRef = useRef(tabId);
|
||||
tabIdRef.current = tabId;
|
||||
|
||||
const handleScheduledSendCreated = useCallback(async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
}
|
||||
closeTab(tabIdRef.current);
|
||||
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
|
||||
|
||||
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
sendData.to,
|
||||
sendData.subject,
|
||||
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
sendData.attachments,
|
||||
sendData.inReplyTo,
|
||||
sendData.references,
|
||||
sendData.delayedUntil,
|
||||
sendData.envelopeMailFrom,
|
||||
);
|
||||
|
||||
if (result.scheduled) {
|
||||
await handleScheduledSendCreated();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original message as $answered / $forwarded so the standard
|
||||
// viewer and list reflect the action (same behaviour as inline compose).
|
||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
||||
@@ -81,7 +98,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(t('notifications.error_sending'));
|
||||
}
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
onSend={handleSend}
|
||||
onScheduledSendCreated={handleScheduledSendCreated}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -104,7 +105,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -116,7 +117,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -128,7 +129,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
@@ -16,6 +18,7 @@ export function PWAInstallPrompt() {
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const t = useTranslations("pwa_install");
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||
@@ -58,7 +61,8 @@ export function PWAInstallPrompt() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoSrc = appLogoLightUrl || faviconUrl;
|
||||
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
|
||||
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
@@ -75,24 +79,24 @@ export function PWAInstallPrompt() {
|
||||
)}
|
||||
{logoSrc && (
|
||||
<img
|
||||
src={appLogoDarkUrl || faviconUrl}
|
||||
src={darkLogoSrc}
|
||||
alt={appName}
|
||||
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||
Install {appName}
|
||||
{t("title", { appName })}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Install our app for quick access and offline support.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
aria-label="Dismiss install prompt"
|
||||
aria-label={t("dismiss_aria")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -103,20 +107,20 @@ export function PWAInstallPrompt() {
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Not now
|
||||
{t("not_now")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Install
|
||||
{t("install")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismissForever}
|
||||
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
||||
>
|
||||
Don't remind me again
|
||||
{t("dont_remind")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
@@ -123,12 +124,12 @@ export function AboutDataSettings() {
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_Color.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_White.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
|
||||
@@ -15,25 +15,7 @@ import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const CALENDAR_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
"#ef4444", // red
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#14b8a6", // teal
|
||||
"#f97316", // orange
|
||||
"#06b6d4", // cyan
|
||||
"#84cc16", // lime
|
||||
"#6366f1", // indigo
|
||||
"#a855f7", // purple
|
||||
"#e11d48", // rose
|
||||
"#0ea5e9", // sky
|
||||
"#10b981", // emerald
|
||||
"#d946ef", // fuchsia
|
||||
];
|
||||
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
|
||||
|
||||
function CalendarColorPicker({
|
||||
value,
|
||||
@@ -182,6 +164,8 @@ export function CalendarManagementSettings() {
|
||||
const tImport = useTranslations('calendar.import');
|
||||
const tSub = useTranslations('calendar.subscription');
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load calendars if not yet loaded
|
||||
@@ -329,6 +313,15 @@ export function CalendarManagementSettings() {
|
||||
|
||||
const handleColorChange = async (calendarId: string, color: string) => {
|
||||
if (!client) return;
|
||||
// Shared calendars: recolor locally only - the viewer usually can't write
|
||||
// the owner's calendar and it'd recolor it for everyone (see #345).
|
||||
const cal = calendars.find((c) => c.id === calendarId);
|
||||
if (cal?.isShared) {
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
toast.success(t('color_updated'));
|
||||
setColorPickerId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateCalendar(client, calendarId, { color });
|
||||
toast.success(t('color_updated'));
|
||||
@@ -396,7 +389,7 @@ export function CalendarManagementSettings() {
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="space-y-2">
|
||||
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
||||
const color = cal.color || '#3b82f6';
|
||||
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
|
||||
|
||||
if (editingId === cal.id) {
|
||||
return (
|
||||
|
||||
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const t = useTranslations('calendar.settings');
|
||||
const tViews = useTranslations('calendar.views');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { viewMode, setViewMode } = useCalendarStore();
|
||||
const {
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
showTimeInMonthView,
|
||||
showWeekNumbers,
|
||||
enableCalendarTasks,
|
||||
@@ -40,28 +37,6 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('week_starts_on')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format_12h') },
|
||||
{ value: '24h', label: t('time_format_24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_time_in_month_view')}
|
||||
description={t('show_time_in_month_view_desc')}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { SendDelaySeconds } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
@@ -22,11 +24,16 @@ export function ComposingSettings() {
|
||||
autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
sendDelaySeconds,
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault,
|
||||
readReceiptResponse,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -37,6 +44,24 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Select
|
||||
value={String(sendDelaySeconds)}
|
||||
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
|
||||
options={[
|
||||
{ value: '0', label: t('send_delay.off') },
|
||||
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
|
||||
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
|
||||
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
|
||||
]}
|
||||
/>
|
||||
{sendDelaySeconds > 0 && !delayedSendSupported && (
|
||||
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
||||
<Select
|
||||
value={signaturePosition}
|
||||
@@ -55,6 +80,25 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('request_read_receipt.label')} description={t('request_read_receipt.description')}>
|
||||
<ToggleSwitch
|
||||
checked={requestReadReceiptDefault}
|
||||
onChange={(checked) => updateSetting('requestReadReceiptDefault', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('read_receipt_response.label')} description={t('read_receipt_response.description')}>
|
||||
<Select
|
||||
value={readReceiptResponse}
|
||||
onChange={(value) => updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')}
|
||||
options={[
|
||||
{ value: 'ask', label: t('read_receipt_response.ask') },
|
||||
{ value: 'always', label: t('read_receipt_response.always') },
|
||||
{ value: 'never', label: t('read_receipt_response.never') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_ATTACHMENT_TEMPLATE,
|
||||
DEFAULT_BUNDLE_TEMPLATE,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
EMAIL_TOKENS,
|
||||
ATTACHMENT_TOKENS,
|
||||
BUNDLE_TOKENS,
|
||||
bundleExportFilename,
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
type EmailFilenameOptions,
|
||||
} from "@/lib/download-filename";
|
||||
|
||||
function insertTokenAtCursor(
|
||||
input: HTMLInputElement,
|
||||
token: string,
|
||||
current: string,
|
||||
onChange: (next: string) => void,
|
||||
): void {
|
||||
const start = input.selectionStart ?? current.length;
|
||||
const end = input.selectionEnd ?? current.length;
|
||||
const before = current.slice(0, start);
|
||||
const after = current.slice(end);
|
||||
const insertion = `{${token}}`;
|
||||
const next = `${before}${insertion}${after}`;
|
||||
onChange(next);
|
||||
// Restore focus and place caret after the inserted token.
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
const caret = before.length + insertion.length;
|
||||
input.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
|
||||
interface TemplateEditorProps {
|
||||
label: string;
|
||||
description: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
tokens: { token: string; description: string }[];
|
||||
preview: string;
|
||||
onChange: (next: string) => void;
|
||||
resetLabel: string;
|
||||
previewLabel: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function TemplateEditor({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
defaultValue,
|
||||
tokens,
|
||||
preview,
|
||||
onChange,
|
||||
resetLabel,
|
||||
previewLabel,
|
||||
placeholder,
|
||||
}: TemplateEditorProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div data-search-label={label} className="space-y-3 py-3 border-b border-border last:border-0">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(defaultValue)}
|
||||
disabled={value === defaultValue}
|
||||
title={resetLabel}
|
||||
className={cn(
|
||||
"px-2 rounded-md border border-border text-foreground transition-colors duration-150",
|
||||
value === defaultValue
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:bg-muted cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tokens.map((t) => (
|
||||
<button
|
||||
key={t.token}
|
||||
type="button"
|
||||
title={t.description}
|
||||
onClick={() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) {
|
||||
onChange(`${value}{${t.token}}`);
|
||||
return;
|
||||
}
|
||||
insertTokenAtCursor(input, t.token, value, onChange);
|
||||
}}
|
||||
className="px-2 py-0.5 text-xs font-mono rounded bg-muted hover:bg-accent border border-border text-foreground transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
{`{${t.token}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="opacity-70">{previewLabel} </span>
|
||||
<span className="font-mono text-foreground/90 break-all">{preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadsSettings() {
|
||||
const t = useTranslations("settings.downloads");
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
attachmentDownloadTemplate,
|
||||
bundleDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
postExportAction,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const sampleEmail = useMemo(() => buildSampleEmail(), []);
|
||||
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
|
||||
|
||||
const transform = useMemo(
|
||||
() => ({
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}),
|
||||
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
|
||||
);
|
||||
|
||||
const emailOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
|
||||
[transform, emailDownloadTemplate],
|
||||
);
|
||||
const attachmentOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
|
||||
[transform, attachmentDownloadTemplate],
|
||||
);
|
||||
const bundleOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: bundleDownloadTemplate || DEFAULT_BUNDLE_TEMPLATE }),
|
||||
[transform, bundleDownloadTemplate],
|
||||
);
|
||||
|
||||
const emlPreview = useMemo(
|
||||
() => emailExportFilename(sampleEmail, emailOptions),
|
||||
[sampleEmail, emailOptions],
|
||||
);
|
||||
const attachmentPreview = useMemo(
|
||||
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
|
||||
[sampleEmail, sampleAttachment, attachmentOptions],
|
||||
);
|
||||
const bundlePreview = useMemo(
|
||||
// Render with the email's fixed sample date so the preview is stable as the
|
||||
// user types in the template field.
|
||||
() => bundleExportFilename(3, bundleOptions, sampleEmail.receivedAt ?? undefined),
|
||||
[bundleOptions, sampleEmail],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<TemplateEditor
|
||||
label={t("email_template.label")}
|
||||
description={t("email_template.description")}
|
||||
value={emailDownloadTemplate}
|
||||
defaultValue={DEFAULT_EMAIL_TEMPLATE}
|
||||
tokens={EMAIL_TOKENS}
|
||||
preview={emlPreview}
|
||||
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_EMAIL_TEMPLATE}
|
||||
/>
|
||||
<TemplateEditor
|
||||
label={t("attachment_template.label")}
|
||||
description={t("attachment_template.description")}
|
||||
value={attachmentDownloadTemplate}
|
||||
defaultValue={DEFAULT_ATTACHMENT_TEMPLATE}
|
||||
tokens={ATTACHMENT_TOKENS}
|
||||
preview={attachmentPreview}
|
||||
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
|
||||
/>
|
||||
<TemplateEditor
|
||||
label={t("bundle_template.label")}
|
||||
description={t("bundle_template.description")}
|
||||
value={bundleDownloadTemplate}
|
||||
defaultValue={DEFAULT_BUNDLE_TEMPLATE}
|
||||
tokens={BUNDLE_TOKENS}
|
||||
preview={bundlePreview}
|
||||
onChange={(next) => updateSetting("bundleDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_BUNDLE_TEMPLATE}
|
||||
/>
|
||||
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
|
||||
<Select
|
||||
value={filenameSpaceReplacement}
|
||||
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
|
||||
options={[
|
||||
{ value: "keep", label: t("spaces.keep") },
|
||||
{ value: "underscore", label: t("spaces.underscore") },
|
||||
{ value: "dash", label: t("spaces.dash") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameLowercase}
|
||||
onChange={(checked) => updateSetting("filenameLowercase", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameStripDiacritics}
|
||||
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameCollapseSeparators}
|
||||
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("after_export.label")} description={t("after_export.description")}>
|
||||
<Select
|
||||
value={postExportAction}
|
||||
onChange={(value) => updateSetting("postExportAction", value as "keep" | "archive" | "trash")}
|
||||
options={[
|
||||
{ value: "keep", label: t("after_export.keep") },
|
||||
{ value: "archive", label: t("after_export.archive") },
|
||||
{ value: "trash", label: t("after_export.trash") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "sm")
|
||||
) : null}
|
||||
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "lg")
|
||||
) : (
|
||||
|
||||
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return `${field} ${comparator} "${c.value}"`;
|
||||
// has_any is a no-value test ("attachment is present"); appending
|
||||
// `""` would look broken in the summary line.
|
||||
if (c.field === "attachment" && c.comparator === "has_any") {
|
||||
return `${field} ${comparator}`;
|
||||
}
|
||||
// Multi-value conditions render as "a" / "b" / "c" with the locale's
|
||||
// OR-glue between items so the line still reads as natural language.
|
||||
const valueStr = Array.isArray(c.value)
|
||||
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
|
||||
: `"${c.value}"`;
|
||||
return `${field} ${comparator} ${valueStr}`;
|
||||
});
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
@@ -97,7 +107,22 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||
<span className="text-muted-foreground">{comparator}</span>
|
||||
<span className="text-foreground">“{c.value}”</span>
|
||||
{!(c.field === "attachment" && c.comparator === "has_any") && (
|
||||
<span className="text-foreground">
|
||||
{Array.isArray(c.value)
|
||||
? c.value.map((v, k) => (
|
||||
<span key={k}>
|
||||
{k > 0 && (
|
||||
<span className="text-muted-foreground/70 italic mx-0.5">
|
||||
{t("or")}
|
||||
</span>
|
||||
)}
|
||||
“{v}”
|
||||
</span>
|
||||
))
|
||||
: <>“{c.value}”</>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const t = useTranslations('settings.language_region');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
|
||||
|
||||
// Subscribe to locale changes so the preview re-renders on language switch
|
||||
// (formatDate reads it via getState() and would otherwise stay stale).
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
// Build sample timestamps for each bucket so users see what their pick
|
||||
// will look like in practice. Use offsets relative to "now" so the
|
||||
// bucketing is stable even though the wall-clock keeps moving.
|
||||
void locale; void dateFormat; void timeFormat;
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setHours(15, 31, 0, 0);
|
||||
const thisWeek = new Date(now);
|
||||
thisWeek.setDate(now.getDate() - 2);
|
||||
thisWeek.setHours(15, 31, 0, 0);
|
||||
const older = new Date(now);
|
||||
older.setMonth(now.getMonth() - 2);
|
||||
older.setHours(15, 31, 0, 0);
|
||||
return {
|
||||
today: formatDate(today),
|
||||
thisWeek: formatDate(thisWeek),
|
||||
older: formatDate(older),
|
||||
};
|
||||
}, [locale, dateFormat, timeFormat]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('language.label')} description={t('language.description')}>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onChange={(value) => updateSetting('dateFormat', value as DateFormat)}
|
||||
options={[
|
||||
{ value: 'smart', label: t('date_format.smart') },
|
||||
{ value: 'relative', label: t('date_format.relative') },
|
||||
{ value: 'full', label: t('date_format.full') },
|
||||
]}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_today')} </span>
|
||||
<span className="text-foreground/90">{preview.today}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
|
||||
<span className="text-foreground/90">{preview.thisWeek}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_older')} </span>
|
||||
<span className="text-foreground/90">{preview.older}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format.12h') },
|
||||
{ value: '24h', label: t('time_format.24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -115,9 +117,11 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -177,10 +181,11 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
locked={isSettingLocked('enableUnifiedMailbox')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
@@ -189,6 +194,21 @@ export function LayoutSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.include_group.label')}
|
||||
description={t('unified_mailbox.include_group.description')}
|
||||
locked={isSettingLocked('includeGroupInUnified')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={includeGroupInUnified}
|
||||
onChange={(v) => updateSetting('includeGroupInUnified', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
|
||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
@@ -40,16 +40,30 @@ export function NotificationSettings() {
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl);
|
||||
const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true;
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const supported = typeof window !== 'undefined' && isWebPushSupported();
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const adminUrl = (adminPushRelayUrl ?? '').trim();
|
||||
const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>(
|
||||
supported ? { kind: 'idle' } : { kind: 'unsupported' },
|
||||
);
|
||||
|
||||
// Pull the admin-configured URL into local state when policy loads/changes.
|
||||
// When locked, the admin value always wins; when only set (not locked), use
|
||||
// it as the initial default but let the user override.
|
||||
useEffect(() => {
|
||||
if (pushRelayLocked && adminUrl) {
|
||||
setRelayUrl(adminUrl);
|
||||
} else if (adminUrl) {
|
||||
setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current));
|
||||
}
|
||||
}, [adminUrl, pushRelayLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!client) return;
|
||||
@@ -125,12 +139,17 @@ export function NotificationSettings() {
|
||||
<SettingsSection title={t('push.title')} description={t('push.description')}>
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-sm font-medium" htmlFor="push-relay-url">
|
||||
<label className="text-sm font-medium inline-flex items-center gap-1.5" htmlFor="push-relay-url">
|
||||
{t('push.relay_label')}
|
||||
{pushRelayLocked && (
|
||||
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
|
||||
)}
|
||||
</label>
|
||||
<PushStatusBadge status={pushStatus} t={t} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
|
||||
</p>
|
||||
<input
|
||||
id="push-relay-url"
|
||||
type="url"
|
||||
@@ -140,7 +159,8 @@ export function NotificationSettings() {
|
||||
value={relayUrl}
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
placeholder={t('push.relay_placeholder')}
|
||||
disabled={busy || pushStatus.kind === 'unsupported'}
|
||||
disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked}
|
||||
readOnly={pushRelayLocked}
|
||||
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -120,9 +120,10 @@ export function ReadingSettings() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
|
||||
options={[
|
||||
{ value: 'trash', label: t('delete_action.trash') },
|
||||
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
|
||||
{ value: 'permanent', label: t('delete_action.permanent') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, Palette, Lock } from 'lucide-react';
|
||||
import { Check, Lock } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
@@ -14,6 +14,18 @@ export function ThemesSettings() {
|
||||
const themePolicy = getThemePolicy();
|
||||
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
||||
|
||||
// Render each preview in the variant matching the app's current mode, and
|
||||
// keep it in sync when the user toggles light/dark elsewhere.
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const update = () => setIsDark(root.classList.contains('dark'));
|
||||
update();
|
||||
const obs = new MutationObserver(update);
|
||||
obs.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Filter out themes disabled by admin policy
|
||||
const visibleThemes = installedThemes.filter(
|
||||
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
|
||||
@@ -36,6 +48,9 @@ export function ThemesSettings() {
|
||||
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
||||
|
||||
const handleActivate = (id: string | null) => {
|
||||
// Clicking the already-active theme is a no-op (no re-apply, no toast).
|
||||
if (id === activeThemeId) return;
|
||||
|
||||
if (forcedThemeId && id !== forcedThemeId) {
|
||||
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||
toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`);
|
||||
@@ -61,6 +76,9 @@ export function ThemesSettings() {
|
||||
<ThemeCard
|
||||
name="Default"
|
||||
author="Bulwark"
|
||||
isDefaultTheme
|
||||
variants={['light', 'dark']}
|
||||
isDark={isDark}
|
||||
isActive={activeThemeId === null}
|
||||
isBuiltIn
|
||||
isDefault={!themePolicy.defaultThemeId}
|
||||
@@ -77,6 +95,8 @@ export function ThemesSettings() {
|
||||
name={theme.name}
|
||||
author={theme.author}
|
||||
preview={theme.preview}
|
||||
css={theme.css}
|
||||
isDark={isDark}
|
||||
isActive={activeThemeId === theme.id}
|
||||
isBuiltIn={theme.builtIn}
|
||||
isDefault={themePolicy.defaultThemeId === theme.id}
|
||||
@@ -98,6 +118,9 @@ interface ThemeCardProps {
|
||||
name: string;
|
||||
author: string;
|
||||
preview?: string;
|
||||
css?: string;
|
||||
isDark?: boolean;
|
||||
isDefaultTheme?: boolean;
|
||||
isActive: boolean;
|
||||
isBuiltIn: boolean;
|
||||
isDefault?: boolean;
|
||||
@@ -107,7 +130,8 @@ interface ThemeCardProps {
|
||||
onActivate: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
|
||||
function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
|
||||
const colors = resolveThemeColors({ css, variants, isDark: !!isDark, isDefaultTheme: !!isDefaultTheme });
|
||||
return (
|
||||
<div data-search-label={name} className="relative">
|
||||
<button
|
||||
@@ -122,12 +146,13 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
|
||||
disabled && !isActive && 'hover:border-border'
|
||||
)}
|
||||
>
|
||||
{/* Preview / Placeholder */}
|
||||
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
||||
{/* Preview: marketplace image when shipped, otherwise a mini app
|
||||
mockup painted from the theme's own colour tokens. */}
|
||||
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted">
|
||||
{preview ? (
|
||||
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Palette className="w-8 h-8 text-muted-foreground/40" />
|
||||
<ThemePreview colors={colors} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -162,3 +187,141 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Theme Preview ───────────────────────────────────────────
|
||||
|
||||
interface ThemeColors {
|
||||
background: string;
|
||||
sidebar: string;
|
||||
card: string;
|
||||
primary: string;
|
||||
primaryForeground: string;
|
||||
foreground: string;
|
||||
mutedForeground: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
// The built-in "Default" theme has no `css` of its own (it's the app's base
|
||||
// tokens); these mirror app/globals.css so its card previews accurately.
|
||||
const DEFAULT_LIGHT: ThemeColors = {
|
||||
background: '#ffffff', sidebar: '#f8fafc', card: '#ffffff', primary: '#3b82f6',
|
||||
primaryForeground: '#ffffff', foreground: '#0f172a', mutedForeground: '#64748b', border: '#e2e8f0',
|
||||
};
|
||||
const DEFAULT_DARK: ThemeColors = {
|
||||
background: '#0a0a0a', sidebar: '#0a0a0a', card: '#141414', primary: '#fafafa',
|
||||
primaryForeground: '#171717', foreground: '#fafafa', mutedForeground: '#a3a3a3', border: '#262626',
|
||||
};
|
||||
|
||||
// Pull a handful of structural colour tokens out of a theme's compiled CSS.
|
||||
// Themes declare light tokens under `:root { … }` and dark under `.dark { … }`;
|
||||
// neither block nests braces, so a non-greedy capture is enough.
|
||||
function extractThemeColors(css: string, dark: boolean): ThemeColors | null {
|
||||
const block = css.match(dark ? /\.dark\s*\{([\s\S]*?)\}/ : /:root\s*\{([\s\S]*?)\}/);
|
||||
if (!block) return null;
|
||||
const body = block[1];
|
||||
const get = (name: string): string | undefined => {
|
||||
const m = body.match(new RegExp(`--color-${name}\\s*:\\s*([^;]+);`));
|
||||
return m ? m[1].trim() : undefined;
|
||||
};
|
||||
const background = get('background');
|
||||
if (!background) return null;
|
||||
return {
|
||||
background,
|
||||
sidebar: get('sidebar') ?? background,
|
||||
card: get('card') ?? background,
|
||||
primary: get('primary') ?? '#888888',
|
||||
primaryForeground: get('primary-foreground') ?? '#ffffff',
|
||||
foreground: get('foreground') ?? '#000000',
|
||||
mutedForeground: get('muted-foreground') ?? '#888888',
|
||||
border: get('border') ?? 'rgba(128,128,128,0.3)',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThemeColors({ css, variants, isDark, isDefaultTheme }: {
|
||||
css?: string;
|
||||
variants?: ('light' | 'dark')[];
|
||||
isDark: boolean;
|
||||
isDefaultTheme: boolean;
|
||||
}): ThemeColors {
|
||||
// Prefer the variant matching the app's current mode; fall back to the one
|
||||
// the theme actually ships if it's single-variant.
|
||||
const hasLight = !variants || variants.includes('light');
|
||||
const hasDark = !variants || variants.includes('dark');
|
||||
const wantDark = (isDark && hasDark) || !hasLight;
|
||||
|
||||
if (isDefaultTheme || !css) return wantDark ? DEFAULT_DARK : DEFAULT_LIGHT;
|
||||
return (
|
||||
extractThemeColors(css, wantDark) ??
|
||||
extractThemeColors(css, !wantDark) ??
|
||||
(wantDark ? DEFAULT_DARK : DEFAULT_LIGHT)
|
||||
);
|
||||
}
|
||||
|
||||
// A miniature of the real three-pane mailbox - icon nav rail, folder sidebar,
|
||||
// message list (first row selected) and reading pane - painted with the
|
||||
// theme's own colours, standing in for a screenshot.
|
||||
function ThemePreview({ colors }: { colors: ThemeColors }) {
|
||||
const rail = `1px solid ${colors.border}`;
|
||||
return (
|
||||
<div className="w-full h-full flex overflow-hidden" style={{ backgroundColor: colors.background }}>
|
||||
{/* Icon nav rail */}
|
||||
<div
|
||||
className="flex flex-col items-center gap-1 py-1.5 shrink-0"
|
||||
style={{ width: '9%', backgroundColor: colors.sidebar, borderRight: rail }}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.primary }} />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Folder sidebar */}
|
||||
<div
|
||||
className="flex flex-col gap-1 p-1.5 shrink-0"
|
||||
style={{ width: '25%', backgroundColor: colors.sidebar, borderRight: rail }}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
|
||||
<div className="h-1 rounded-full flex-1" style={{ backgroundColor: colors.foreground, opacity: 0.55 }} />
|
||||
</div>
|
||||
{[0.8, 0.65, 0.72, 0.5].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-sm shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.45 }} />
|
||||
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
<div className="flex flex-col shrink-0" style={{ width: '36%', backgroundColor: colors.background, borderRight: rail }}>
|
||||
{/* Selected row */}
|
||||
<div className="flex items-start gap-1 p-1" style={{ backgroundColor: colors.primary }}>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.primaryForeground, opacity: 0.85 }} />
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<div className="h-1 rounded-full" style={{ width: '70%', backgroundColor: colors.primaryForeground, opacity: 0.9 }} />
|
||||
<div className="h-1 rounded-full" style={{ width: '90%', backgroundColor: colors.primaryForeground, opacity: 0.55 }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Unread + read rows */}
|
||||
{[0.6, 0.7, 0.55].map((w, i) => (
|
||||
<div key={i} className="flex items-start gap-1 p-1" style={{ borderTop: rail }}>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.foreground, opacity: i === 0 ? 0.7 : 0.5 }} />
|
||||
<div className="h-1 rounded-full" style={{ width: '85%', backgroundColor: colors.mutedForeground, opacity: 0.35 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reading pane */}
|
||||
<div className="flex-1 flex flex-col gap-1 p-1.5 min-w-0">
|
||||
<div className="h-1.5 rounded-full" style={{ width: '70%', backgroundColor: colors.foreground, opacity: 0.85 }} />
|
||||
<div className="h-1 rounded-full mb-1" style={{ width: '40%', backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
|
||||
{[0.95, 0.85, 0.9, 0.6].map((w, i) => (
|
||||
<div key={i} className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.3 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||
|
||||
@@ -269,6 +270,15 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={handleImgError}
|
||||
// /api/favicon returns a 1x1 transparent PNG (HTTP 200) when no real
|
||||
// favicon exists, to avoid spamming the DevTools console with 404s.
|
||||
// Detect that sentinel by naturalWidth and fall back to initials.
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (isFavicon && img.naturalWidth <= 1) {
|
||||
handleImgError();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
getInitials()
|
||||
|
||||
@@ -98,6 +98,17 @@ export function FlagDE(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hungary – Red, White, Green horizontal */
|
||||
export function FlagHU(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 3" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="6" height="1" fill="#CD2A3E" />
|
||||
<rect y="1" width="6" height="1" fill="#fff" />
|
||||
<rect y="2" width="6" height="1" fill="#436F4D" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Latvia – Maroon, White, Maroon horizontal */
|
||||
export function FlagLV(props: FlagProps) {
|
||||
return (
|
||||
@@ -219,6 +230,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
en: FlagGB,
|
||||
es: FlagES,
|
||||
fr: FlagFR,
|
||||
hu: FlagHU,
|
||||
it: FlagIT,
|
||||
ja: FlagJP,
|
||||
ko: FlagKR,
|
||||
|
||||
@@ -15,6 +15,7 @@ const languages = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'hu', label: 'Magyar' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
|
||||
@@ -92,9 +92,11 @@ export function useAttachmentDrag(
|
||||
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}`);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
|
||||
// raw - URL-encoding it lands literally on disk (`%20` instead of a
|
||||
// space). Callers are expected to sanitise reserved chars (`:` etc.)
|
||||
// beforehand.
|
||||
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
},
|
||||
[source.name, source.type, prefetch],
|
||||
|
||||
+202
-5
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useCallback, DragEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
||||
import {
|
||||
bundleExportFilename,
|
||||
DEFAULT_BUNDLE_TEMPLATE,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
emailExportFilename,
|
||||
type EmailFilenameOptions,
|
||||
} from "@/lib/download-filename";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface UseEmailDragOptions {
|
||||
email: Email;
|
||||
@@ -15,6 +26,7 @@ interface UseEmailDragOptions {
|
||||
interface UseEmailDragReturn {
|
||||
dragHandlers: {
|
||||
draggable: boolean;
|
||||
onPointerEnter?: () => void;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
@@ -44,10 +56,153 @@ function createDragPreview(count: number): HTMLElement {
|
||||
return preview;
|
||||
}
|
||||
|
||||
function bundleFilename(count: number, options: EmailFilenameOptions): string {
|
||||
return bundleExportFilename(count, options);
|
||||
}
|
||||
|
||||
// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in
|
||||
// the selection, so two rows in the same selection reuse the same in-flight
|
||||
// build. When the selection changes, the previous bundle URL is scheduled for
|
||||
// revoke and a new build starts.
|
||||
type BundleEntry = {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
promise: Promise<string | null> | null;
|
||||
};
|
||||
|
||||
let currentBundle: BundleEntry | null = null;
|
||||
|
||||
function selectionKey(ids: string[]): string {
|
||||
return [...ids].sort().join(",");
|
||||
}
|
||||
|
||||
async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise<string | null> {
|
||||
const eligible = emails.filter((em) => !!em.blobId);
|
||||
if (eligible.length === 0) return null;
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
await Promise.all(
|
||||
eligible.map(async (em) => {
|
||||
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
|
||||
let name = `${base}.eml`;
|
||||
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
||||
used.add(name);
|
||||
try {
|
||||
const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822");
|
||||
zip.file(name, blob);
|
||||
} catch {
|
||||
// Skip individual failures; remaining messages still bundle.
|
||||
}
|
||||
}),
|
||||
);
|
||||
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
|
||||
return URL.createObjectURL(zipBlob);
|
||||
}
|
||||
|
||||
function prefetchEmailBundle(
|
||||
client: IJMAPClient,
|
||||
emails: Email[],
|
||||
emailOptions: EmailFilenameOptions,
|
||||
bundleOptions: EmailFilenameOptions,
|
||||
): void {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key) return;
|
||||
if (currentBundle?.url) {
|
||||
const old = currentBundle.url;
|
||||
setTimeout(() => URL.revokeObjectURL(old), 60_000);
|
||||
}
|
||||
const entry: BundleEntry = {
|
||||
key,
|
||||
name: bundleFilename(emails.length, bundleOptions),
|
||||
url: null,
|
||||
promise: null,
|
||||
};
|
||||
entry.promise = buildEmailZip(client, emails, emailOptions)
|
||||
.then((url) => {
|
||||
if (url && currentBundle === entry) entry.url = url;
|
||||
return url;
|
||||
})
|
||||
.catch(() => null);
|
||||
currentBundle = entry;
|
||||
}
|
||||
|
||||
function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key && currentBundle.url) {
|
||||
return { url: currentBundle.url, name: currentBundle.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||
const { selectedEmailIds, emails } = useEmailStore();
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const bundleTemplate = useSettingsStore((s) => s.bundleDownloadTemplate) || DEFAULT_BUNDLE_TEMPLATE;
|
||||
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.filenameLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
|
||||
const collapseSeparators = useSettingsStore((s) => s.filenameCollapseSeparators);
|
||||
const filenameOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
const bundleOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template: bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
|
||||
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
|
||||
const singleBlobUrlRef = useRef<string | null>(null);
|
||||
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (singleBlobUrlRef.current) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
singleBlobUrlRef.current = null;
|
||||
// Defer revoke - Chromium asynchronously reads the blob: URL after the
|
||||
// drop completes, so revoking immediately can race the OS.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
inFlightRef.current = null;
|
||||
};
|
||||
}, [email.id]);
|
||||
|
||||
const prefetchSingle = useCallback(() => {
|
||||
if (!dragOutEnabled || !client || !email.blobId) return;
|
||||
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
||||
const name = emailExportFilename(email, filenameOptions);
|
||||
inFlightRef.current = client
|
||||
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
||||
.then((url) => {
|
||||
if (url && !singleBlobUrlRef.current) singleBlobUrlRef.current = url;
|
||||
return url;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inFlightRef.current = null;
|
||||
});
|
||||
}, [dragOutEnabled, client, email, filenameOptions]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!dragOutEnabled || !client) return;
|
||||
const isSelected = selectedEmailIds.has(email.id);
|
||||
const isMulti = isSelected && selectedEmailIds.size > 1;
|
||||
if (isMulti) {
|
||||
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
|
||||
// Only worth bundling when at least one selected email has a blobId.
|
||||
if (selected.some((em) => em.blobId)) {
|
||||
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
|
||||
}
|
||||
} else {
|
||||
prefetchSingle();
|
||||
}
|
||||
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
@@ -59,8 +214,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||
: threadEmails || [email];
|
||||
|
||||
// Set data transfer
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData(
|
||||
"application/x-email-ids",
|
||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
||||
@@ -70,6 +224,40 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
|
||||
);
|
||||
|
||||
// Drag-out to file explorer.
|
||||
if (dragOutEnabled && client) {
|
||||
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
if (url) {
|
||||
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
|
||||
// the filename raw - URL-encoding it ends up literally on disk
|
||||
// (e.g. `%20` instead of a space). The sanitiser already removed
|
||||
// `:` and other reserved chars, so embedding the name as-is is
|
||||
// safe. Firefox/Safari ignore this entry entirely.
|
||||
e.dataTransfer.setData(
|
||||
"DownloadURL",
|
||||
`message/rfc822:${name}:${url}`,
|
||||
);
|
||||
} else {
|
||||
// Not warmed up yet — kick off so the next attempt works. Don't
|
||||
// preventDefault: in-app drop still has to function.
|
||||
prefetchSingle();
|
||||
}
|
||||
} else if (emailsToDrag.length > 1) {
|
||||
const ready = getReadyBundle(emailsToDrag);
|
||||
if (ready) {
|
||||
e.dataTransfer.setData(
|
||||
"DownloadURL",
|
||||
`application/zip:${ready.name}:${ready.url}`,
|
||||
);
|
||||
} else {
|
||||
// Kick off the bundle build for the next attempt.
|
||||
prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom drag image
|
||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
@@ -80,10 +268,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
// Defer-revoke the per-row single .eml URL. The shared bundle URL stays
|
||||
// cached until the selection changes - revoking it here would break a
|
||||
// subsequent drag of the same selection.
|
||||
if (singleBlobUrlRef.current) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
singleBlobUrlRef.current = null;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}, [endDrag]);
|
||||
|
||||
// Check if this specific email is being dragged
|
||||
@@ -94,6 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
||||
: {
|
||||
draggable: true,
|
||||
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
},
|
||||
|
||||
@@ -289,6 +289,9 @@ export const KEYBOARD_SHORTCUTS = {
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||
],
|
||||
composer: [
|
||||
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
|
||||
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
+53
-20
@@ -21,6 +21,27 @@ function resolveSourceAccountId(email: Email | undefined): string | null {
|
||||
return useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local accountId ("user@host") that owns the destination
|
||||
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
|
||||
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
|
||||
* namespace. We map back by matching the JMAP id against each connected
|
||||
* client's `getAccountId()`. Falls back to the viewing/active account so
|
||||
* single-account flows (no connected clients map entry yet, in-memory edits,
|
||||
* etc.) still resolve correctly.
|
||||
*/
|
||||
function resolveDestAccountId(mailbox: Mailbox): string | null {
|
||||
const jmapId = mailbox.accountId;
|
||||
if (jmapId) {
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
for (const [localId, client] of clients) {
|
||||
if (client.getAccountId() === jmapId) return localId;
|
||||
}
|
||||
}
|
||||
return useEmailStore.getState().viewingAccountId
|
||||
?? useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
onDropComplete?: () => void;
|
||||
@@ -60,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||
if (mailbox.id.startsWith("shared-")) return false;
|
||||
|
||||
// Shared (delegated) mailboxes still require the source to belong to the
|
||||
// same delegating account. Real cross-account moves between primary
|
||||
// accounts go through the cross-account path further down, but the
|
||||
// shared-folder semantics here are about ACLs rather than transport, so
|
||||
// they remain disallowed.
|
||||
if (mailbox.isShared && draggedEmails[0]) {
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
if (sourceMb?.accountId !== mailbox.accountId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
||||
}, [isDragging, mailbox, sourceMailboxId]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -123,7 +132,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Group dragged emails by source account. In single-account flows this
|
||||
// collapses to one bucket; in unified view or the Pro multi-account
|
||||
// sidebar a single drag can mix sources.
|
||||
const destAccountId = mailbox.accountId;
|
||||
const destAccountId = resolveDestAccountId(mailbox);
|
||||
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
|
||||
const bySource = new Map<string, string[]>();
|
||||
for (const id of emailIds) {
|
||||
@@ -133,18 +142,42 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
bySource.get(srcAccountId)!.push(id);
|
||||
}
|
||||
|
||||
// Decide whether to route via the cross-account (blob copy + import)
|
||||
// pipeline. Two cases require it:
|
||||
// 1. Destination is a delegated/shared mailbox whose owner JMAP
|
||||
// account differs from the source mailbox's JMAP account. There's
|
||||
// no atomic Email/set across accounts, even via the same client.
|
||||
// 2. Destination is a primary mailbox on a different connected local
|
||||
// account than the source — the historical multi-account case.
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
const sourceJmapAccountId = sourceMb?.accountId;
|
||||
const destJmapAccountId = mailbox.accountId;
|
||||
const sourceAccountIds = Array.from(bySource.keys());
|
||||
const isCrossAccount =
|
||||
!!destAccountId &&
|
||||
|
||||
const isJmapCrossAccount =
|
||||
!!sourceJmapAccountId &&
|
||||
!!destJmapAccountId &&
|
||||
sourceJmapAccountId !== destJmapAccountId;
|
||||
const isLocalCrossAccount =
|
||||
!mailbox.isShared &&
|
||||
!!destAccountId &&
|
||||
sourceAccountIds.some((src) => src !== destAccountId);
|
||||
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
|
||||
|
||||
if (isCrossAccount) {
|
||||
// JMAP can't natively move an email between primary accounts, so the
|
||||
// store reuploads each source blob into the destination account and
|
||||
// then deletes the original.
|
||||
if (!destAccountId) {
|
||||
throw new Error('Could not resolve destination account');
|
||||
}
|
||||
// For a shared destination there is no separately-connected client
|
||||
// for the owner; we reuse the viewing user's client but tell the
|
||||
// import call to target the owner's JMAP account.
|
||||
const jmapDestId = mailbox.originalId || mailbox.id;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
|
||||
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
// Mirror image for the source: a shared group mailbox is accessed
|
||||
// through the viewing user's client, but the email/blob live in the
|
||||
// owner's JMAP account, so the copy/delete must target it.
|
||||
const sourceJmapOverride = sourceMb?.isShared ? sourceMb.accountId : undefined;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride);
|
||||
} else {
|
||||
// Single-account or same-account-shared move: bulk JMAP request.
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
|
||||
+14
-2
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
// set this via NEXT_PUBLIC_DEFAULT_LOCALE at build time to localise greenfield
|
||||
// deployments without having every user change their preference manually.
|
||||
const envDefaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE?.trim();
|
||||
const resolvedDefaultLocale =
|
||||
envDefaultLocale && (SUPPORTED_LOCALES as readonly string[]).includes(envDefaultLocale)
|
||||
? (envDefaultLocale as (typeof SUPPORTED_LOCALES)[number])
|
||||
: 'en';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
locales: SUPPORTED_LOCALES,
|
||||
defaultLocale: resolvedDefaultLocale,
|
||||
localePrefix
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
// `browser-navigation` reads NEXT_PUBLIC_BASE_PATH into a module-load constant
|
||||
// (mirroring how next.config.ts bakes basePath in at build time), so each case
|
||||
// has to re-import the module under a fresh env. These tests pin down the
|
||||
// promise made to subpath deployments: the built-in branding *defaults*
|
||||
// (FAVICON_URL, LOGIN_LOGO_*) — not just admin-set custom values — get the
|
||||
// mount prefix, because every consumer wraps them in withBasePath(). See #330.
|
||||
async function loadNav(basePath?: string) {
|
||||
vi.resetModules();
|
||||
if (basePath === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_BASE_PATH;
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_BASE_PATH = basePath;
|
||||
}
|
||||
return import('@/lib/browser-navigation');
|
||||
}
|
||||
|
||||
// The fallback values from CONFIG_ENV_MAP that ship in the box.
|
||||
const DEFAULT_FAVICON = '/branding/Bulwark_Favicon.svg';
|
||||
const DEFAULT_LOGIN_LOGO_LIGHT = '/branding/Bulwark_Logo_Color.svg';
|
||||
const DEFAULT_LOGIN_LOGO_DARK = '/branding/Bulwark_Logo_White.svg';
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_BASE_PATH;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('withBasePath — asset-URL fallbacks under a subpath', () => {
|
||||
it('leaves branding defaults untouched when no base path is set', async () => {
|
||||
const { withBasePath } = await loadNav(undefined);
|
||||
// jsdom's window.location.pathname is "/", so runtime detection finds no
|
||||
// prefix either — the default paths pass through unchanged.
|
||||
expect(withBasePath(DEFAULT_FAVICON)).toBe(DEFAULT_FAVICON);
|
||||
expect(withBasePath(DEFAULT_LOGIN_LOGO_LIGHT)).toBe(DEFAULT_LOGIN_LOGO_LIGHT);
|
||||
expect(withBasePath(DEFAULT_LOGIN_LOGO_DARK)).toBe(DEFAULT_LOGIN_LOGO_DARK);
|
||||
});
|
||||
|
||||
it('prefixes the built-in favicon and login-logo defaults', async () => {
|
||||
const { withBasePath } = await loadNav('/webmail');
|
||||
expect(withBasePath(DEFAULT_FAVICON)).toBe('/webmail/branding/Bulwark_Favicon.svg');
|
||||
expect(withBasePath(DEFAULT_LOGIN_LOGO_LIGHT)).toBe('/webmail/branding/Bulwark_Logo_Color.svg');
|
||||
expect(withBasePath(DEFAULT_LOGIN_LOGO_DARK)).toBe('/webmail/branding/Bulwark_Logo_White.svg');
|
||||
});
|
||||
|
||||
it('is idempotent for an already-prefixed value (no double prefix)', async () => {
|
||||
const { withBasePath } = await loadNav('/webmail');
|
||||
expect(withBasePath('/webmail/branding/Bulwark_Favicon.svg')).toBe('/webmail/branding/Bulwark_Favicon.svg');
|
||||
expect(withBasePath('/webmail')).toBe('/webmail');
|
||||
});
|
||||
|
||||
it('passes through external, protocol-relative, data, and empty values', async () => {
|
||||
const { withBasePath } = await loadNav('/webmail');
|
||||
expect(withBasePath('https://cdn.example.com/logo.svg')).toBe('https://cdn.example.com/logo.svg');
|
||||
expect(withBasePath('//cdn.example.com/logo.svg')).toBe('//cdn.example.com/logo.svg');
|
||||
expect(withBasePath('data:image/svg+xml,<svg/>')).toBe('data:image/svg+xml,<svg/>');
|
||||
expect(withBasePath('')).toBe('');
|
||||
expect(withBasePath(null)).toBe('');
|
||||
expect(withBasePath(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('strips a trailing slash on the configured base path', async () => {
|
||||
const { withBasePath, getPathPrefix } = await loadNav('/webmail/');
|
||||
expect(getPathPrefix()).toBe('/webmail');
|
||||
expect(withBasePath(DEFAULT_FAVICON)).toBe('/webmail/branding/Bulwark_Favicon.svg');
|
||||
});
|
||||
|
||||
it('getPathPrefix reports the static base path on the server (no window needed)', async () => {
|
||||
const { getPathPrefix } = await loadNav('/webmail');
|
||||
// SSR consumers (layout.tsx favicon metadata, manifest.ts) rely on this
|
||||
// resolving from the build-time constant rather than window.location.
|
||||
expect(getPathPrefix()).toBe('/webmail');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import { BUILTIN_THEMES } from '../builtin-themes';
|
||||
|
||||
describe('BUILTIN_THEMES', () => {
|
||||
it('contains exactly 3 themes', () => {
|
||||
expect(BUILTIN_THEMES).toHaveLength(3);
|
||||
it('contains exactly 6 themes', () => {
|
||||
expect(BUILTIN_THEMES).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('all themes have required fields', () => {
|
||||
@@ -43,6 +43,8 @@ describe('BUILTIN_THEMES', () => {
|
||||
expect(names).toContain('Nord');
|
||||
expect(names).toContain('Catppuccin');
|
||||
expect(names).toContain('Solarized');
|
||||
expect(names).toContain('Roundcube Elastic');
|
||||
expect(names).toContain('Aurora Glass');
|
||||
});
|
||||
|
||||
it('theme IDs are unique', () => {
|
||||
|
||||
@@ -36,16 +36,29 @@ describe('config API route', () => {
|
||||
delete process.env.LOGIN_IMPRINT_URL;
|
||||
delete process.env.LOGIN_PRIVACY_POLICY_URL;
|
||||
delete process.env.LOGIN_WEBSITE_URL;
|
||||
delete process.env.DOMAIN_BRANDING;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
async function getConfig() {
|
||||
function mockRequest(headers: Record<string, string> = {}): unknown {
|
||||
const lc: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
|
||||
return {
|
||||
headers: {
|
||||
get(name: string) {
|
||||
return lc[name.toLowerCase()] ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getConfig(headers?: Record<string, string>) {
|
||||
// Re-import to pick up env changes
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
const response = await GET();
|
||||
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -192,4 +205,70 @@ describe('config API route', () => {
|
||||
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
|
||||
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
|
||||
});
|
||||
|
||||
describe('per-domain branding overrides', () => {
|
||||
it('applies overrides for the matching host', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{
|
||||
host: 'mail1.example.com',
|
||||
loginCompanyName: 'Brand One',
|
||||
loginWebsiteUrl: 'https://one.example',
|
||||
},
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Brand One');
|
||||
expect(config.loginWebsiteUrl).toBe('https://one.example');
|
||||
});
|
||||
|
||||
it('falls through to the global value when the host has no entry', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'unmapped.example.com' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Default Co');
|
||||
});
|
||||
|
||||
it('falls through field-by-field when the matching entry omits a field', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Brand One');
|
||||
expect(config.loginWebsiteUrl).toBe('https://default.example');
|
||||
});
|
||||
|
||||
it('prefers X-Forwarded-Host over Host', async () => {
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'public.example.com', loginCompanyName: 'Public' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({
|
||||
host: 'internal.example.com',
|
||||
'x-forwarded-host': 'public.example.com',
|
||||
});
|
||||
|
||||
expect(config.loginCompanyName).toBe('Public');
|
||||
});
|
||||
|
||||
it('strips the port from the host header before matching', async () => {
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com:8443' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Brand One');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type DomainBrandingEntry,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
function mockHeaders(map: Record<string, string>): Headers {
|
||||
const lc: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v;
|
||||
return {
|
||||
get(name: string) {
|
||||
return lc[name.toLowerCase()] ?? null;
|
||||
},
|
||||
} as unknown as Headers;
|
||||
}
|
||||
|
||||
describe('parseDomainBranding', () => {
|
||||
it('returns [] for null/undefined/empty', () => {
|
||||
expect(parseDomainBranding(null)).toEqual([]);
|
||||
expect(parseDomainBranding(undefined)).toEqual([]);
|
||||
expect(parseDomainBranding('')).toEqual([]);
|
||||
expect(parseDomainBranding([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a stringified JSON array', () => {
|
||||
const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]);
|
||||
expect(parseDomainBranding(raw)).toEqual([
|
||||
{ host: 'mail.example.com', loginCompanyName: 'Acme' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lower-cases hosts and strips trailing dots', () => {
|
||||
expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([
|
||||
{ host: 'mail.example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts wildcard hosts', () => {
|
||||
expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([
|
||||
{ host: '*.example.com', loginCompanyName: 'Wild' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops entries with invalid hosts', () => {
|
||||
const out = parseDomainBranding([
|
||||
{ host: '' },
|
||||
{ host: 'has space.com' },
|
||||
{ host: 'foo..bar' },
|
||||
{ host: '*.*.example.com' }, // embedded wildcard not allowed
|
||||
{ host: 'good.example.com' },
|
||||
]);
|
||||
expect(out.map(e => e.host)).toEqual(['good.example.com']);
|
||||
});
|
||||
|
||||
it('drops duplicate hosts, keeping the first', () => {
|
||||
const out = parseDomainBranding([
|
||||
{ host: 'foo.com', loginCompanyName: 'First' },
|
||||
{ host: 'FOO.com', loginCompanyName: 'Second' },
|
||||
]);
|
||||
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]);
|
||||
});
|
||||
|
||||
it('ignores non-string and empty-string override fields', () => {
|
||||
const out = parseDomainBranding([
|
||||
{
|
||||
host: 'foo.com',
|
||||
loginCompanyName: '',
|
||||
loginImprintUrl: 42,
|
||||
loginWebsiteUrl: 'https://foo.com',
|
||||
},
|
||||
]);
|
||||
expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]);
|
||||
});
|
||||
|
||||
it('ignores unknown fields', () => {
|
||||
const out = parseDomainBranding([
|
||||
{ host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' },
|
||||
]);
|
||||
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickRequestHost', () => {
|
||||
it('returns null when no host headers are set', () => {
|
||||
expect(pickRequestHost(mockHeaders({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers X-Forwarded-Host over Host', () => {
|
||||
expect(pickRequestHost(mockHeaders({
|
||||
'x-forwarded-host': 'forwarded.example.com',
|
||||
host: 'origin.example.com',
|
||||
}))).toBe('forwarded.example.com');
|
||||
});
|
||||
|
||||
it('falls back to Host when X-Forwarded-Host is absent', () => {
|
||||
expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com');
|
||||
});
|
||||
|
||||
it('strips the port', () => {
|
||||
expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com');
|
||||
});
|
||||
|
||||
it('takes the first entry of a comma-separated X-Forwarded-Host', () => {
|
||||
expect(pickRequestHost(mockHeaders({
|
||||
'x-forwarded-host': 'first.example.com, second.example.com',
|
||||
}))).toBe('first.example.com');
|
||||
});
|
||||
|
||||
it('lower-cases the result', () => {
|
||||
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchDomainBranding', () => {
|
||||
const entries: DomainBrandingEntry[] = [
|
||||
{ host: 'mail.example.com', loginCompanyName: 'Exact' },
|
||||
{ host: '*.example.com', loginCompanyName: 'Wildcard' },
|
||||
{ host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' },
|
||||
{ host: 'other.com', loginCompanyName: 'Other' },
|
||||
];
|
||||
|
||||
it('returns {} when host is null', () => {
|
||||
expect(matchDomainBranding(null, entries)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when no entries match', () => {
|
||||
expect(matchDomainBranding('unknown.org', entries)).toEqual({});
|
||||
});
|
||||
|
||||
it('prefers exact match over wildcard', () => {
|
||||
expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact');
|
||||
});
|
||||
|
||||
it('matches wildcards on subdomains', () => {
|
||||
expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard');
|
||||
});
|
||||
|
||||
it('prefers the longest wildcard suffix', () => {
|
||||
expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard');
|
||||
});
|
||||
|
||||
it('does not match the wildcard host against the apex domain', () => {
|
||||
expect(matchDomainBranding('example.com', entries)).toEqual({});
|
||||
});
|
||||
|
||||
it('is case-insensitive on the request host', () => {
|
||||
expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
import {
|
||||
plainTextToComposerBody,
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
INLINE_IMAGE_PLACEHOLDER,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteCidImagesForEditor", () => {
|
||||
it("returns input unchanged when no cid: refs are present", () => {
|
||||
const html = '<p>hi</p><img src="https://example.com/x.png">';
|
||||
expect(rewriteCidImagesForEditor(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(rewriteCidImagesForEditor("")).toBe("");
|
||||
});
|
||||
|
||||
it("rewrites a cid: src to placeholder + data-cid", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc@x" alt="logo">'
|
||||
);
|
||||
expect(out).toContain('data-cid="abc@x"');
|
||||
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
|
||||
expect(out).toContain('alt="logo"');
|
||||
expect(out).not.toContain('src="cid:');
|
||||
});
|
||||
|
||||
it("preserves an existing data-cid attribute", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc" data-cid="kept">'
|
||||
);
|
||||
expect(out).toContain('data-cid="kept"');
|
||||
expect(out).not.toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("leaves non-cid images alone", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="https://example.com/x.png"><img src="cid:y">'
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('data-cid="y"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceInlineImagePlaceholders", () => {
|
||||
it("returns input unchanged when the map is empty", () => {
|
||||
const html = '<img src="..." data-cid="x">';
|
||||
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
|
||||
});
|
||||
|
||||
it("swaps the placeholder src to the data URL for matching cids", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
expect(out).toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("also rewrites raw cid: src refs that lack a placeholder", () => {
|
||||
const html = '<img src="cid:abc" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
it("does not overwrite images the user has re-pointed away from the cid", () => {
|
||||
const html =
|
||||
'<img src="https://example.com/other.png" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/other.png"');
|
||||
expect(out).not.toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves unknown cids untouched", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSecurityStatus,
|
||||
parseSpamLLM,
|
||||
extractListHeaders,
|
||||
isAuthenticationSpoofed,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
@@ -52,6 +53,66 @@ describe('parseAuthenticationResults', () => {
|
||||
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
|
||||
expect(result.spf?.result).toBe('softfail');
|
||||
});
|
||||
|
||||
it('surfaces the most severe result when multiple SPF identities exist', () => {
|
||||
// HELO temperror, MAIL FROM fail — the harder fail must be the headline.
|
||||
const header =
|
||||
'mx.example.com; spf=temperror (mx: dns timeout) smtp.helo=mail.spoof.com; spf=fail (mx: not authorized) smtp.mailfrom=victim.com';
|
||||
const result = parseAuthenticationResults(header);
|
||||
expect(result.spf?.result).toBe('fail');
|
||||
expect(result.spf?.domain).toBe('victim.com');
|
||||
});
|
||||
|
||||
it('exposes all SPF results when more than one identity is evaluated', () => {
|
||||
const header =
|
||||
'spf=temperror smtp.helo=mail.spoof.com; spf=fail smtp.mailfrom=victim.com';
|
||||
const result = parseAuthenticationResults(header);
|
||||
expect(result.spf?.all).toEqual([
|
||||
{ result: 'temperror', identity: 'helo', domain: 'mail.spoof.com' },
|
||||
{ result: 'fail', identity: 'mailfrom', domain: 'victim.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not set `all` for a single SPF result', () => {
|
||||
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
|
||||
expect(result.spf?.all).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the MAIL FROM identity when severities tie', () => {
|
||||
const header = 'spf=pass smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com';
|
||||
const result = parseAuthenticationResults(header);
|
||||
expect(result.spf?.domain).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticationSpoofed', () => {
|
||||
it('returns false when no auth results are present', () => {
|
||||
expect(isAuthenticationSpoofed(undefined)).toBe(false);
|
||||
expect(isAuthenticationSpoofed({})).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a DMARC fail as spoofed', () => {
|
||||
expect(isAuthenticationSpoofed({ dmarc: { result: 'fail' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a hard SPF fail without a passing DKIM as spoofed', () => {
|
||||
expect(isAuthenticationSpoofed({ spf: { result: 'fail' } })).toBe(true);
|
||||
expect(
|
||||
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'fail' } })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag an SPF fail rescued by a passing DKIM', () => {
|
||||
expect(
|
||||
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'pass' } })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag passing or ambiguous results', () => {
|
||||
expect(isAuthenticationSpoofed({ spf: { result: 'pass' }, dmarc: { result: 'pass' } })).toBe(false);
|
||||
expect(isAuthenticationSpoofed({ spf: { result: 'softfail' } })).toBe(false);
|
||||
expect(isAuthenticationSpoofed({ spf: { result: 'temperror' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSpamScore', () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { snapshotHostTheme, themeSnapshotToCSS, type ThemeSnapshot } from '../plugin-sandbox/host-theme';
|
||||
|
||||
describe('host-theme', () => {
|
||||
describe('themeSnapshotToCSS', () => {
|
||||
it('emits the token values, font, and color-scheme', () => {
|
||||
const snapshot: ThemeSnapshot = {
|
||||
dark: false,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
vars: { '--color-background': '#ffffff', '--color-foreground': '#0f172a' },
|
||||
};
|
||||
const css = themeSnapshotToCSS(snapshot);
|
||||
expect(css).toContain('--color-background: #ffffff;');
|
||||
expect(css).toContain('--color-foreground: #0f172a;');
|
||||
expect(css).toContain('font-family: Inter, sans-serif;');
|
||||
expect(css).toContain('color-scheme: light;');
|
||||
// Body inherits the theme foreground so unstyled plugin text adapts.
|
||||
expect(css).toContain('color: var(--color-foreground, inherit);');
|
||||
expect(css).toContain('background: transparent;');
|
||||
});
|
||||
|
||||
it('reports a dark color-scheme when dark', () => {
|
||||
const css = themeSnapshotToCSS({ dark: true, fontFamily: 'sans-serif', vars: {} });
|
||||
expect(css).toContain('color-scheme: dark;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotHostTheme', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.removeAttribute('style');
|
||||
});
|
||||
|
||||
it('reads the dark flag and declared tokens off <html>', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.setProperty('--color-background', '#0a0a0a');
|
||||
const snapshot = snapshotHostTheme();
|
||||
expect(snapshot.dark).toBe(true);
|
||||
expect(snapshot.vars['--color-background']).toBe('#0a0a0a');
|
||||
expect(snapshot.fontFamily).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
function enableDelayedSend(client: JMAPClient) {
|
||||
Object.assign(client, {
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:core': {},
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
},
|
||||
session: {
|
||||
primaryAccounts: {
|
||||
'urn:ietf:params:jmap:mail': 'account-1',
|
||||
'urn:ietf:params:jmap:submission': 'submission-account-1',
|
||||
},
|
||||
accounts: {
|
||||
'account-1': {
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
},
|
||||
},
|
||||
'submission-account-1': {
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface JMAPMethodCall {
|
||||
0: string;
|
||||
1: Record<string, unknown>;
|
||||
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
|
||||
payload = {
|
||||
methodResponses: [
|
||||
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
|
||||
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
|
||||
['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => {
|
||||
expect(draft.inReplyTo).toEqual(['real@example.com']);
|
||||
expect(draft.references).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
|
||||
const client = createClient();
|
||||
enableDelayedSend(client);
|
||||
const captured = mockSendEmailFlow();
|
||||
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
const result = await client.sendEmail(
|
||||
['recipient@example.com'],
|
||||
'Scheduled test',
|
||||
'body',
|
||||
undefined, undefined, 'identity-1', 'user@example.com',
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
delayedUntil,
|
||||
);
|
||||
|
||||
const identityRequest = captured[1];
|
||||
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
|
||||
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
|
||||
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
|
||||
expect(submissionCall?.[1].create).toEqual({
|
||||
'1': {
|
||||
emailId: expect.stringMatching(/^#send-/),
|
||||
identityId: 'identity-1',
|
||||
envelope: {
|
||||
mailFrom: {
|
||||
email: 'user@example.com',
|
||||
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
|
||||
},
|
||||
rcptTo: [{ email: 'recipient@example.com' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
|
||||
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
|
||||
});
|
||||
|
||||
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
|
||||
const client = createClient();
|
||||
enableDelayedSend(client);
|
||||
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
|
||||
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
||||
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
||||
] as never);
|
||||
vi.spyOn(client, 'getIdentities').mockResolvedValue([
|
||||
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
|
||||
]);
|
||||
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
|
||||
.mockImplementation(async (methodCalls) => {
|
||||
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
|
||||
if (args.create) {
|
||||
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
|
||||
}
|
||||
if (args.update?.['sub-old']) {
|
||||
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
|
||||
}
|
||||
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
|
||||
});
|
||||
|
||||
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
|
||||
.rejects.toThrow('could not cancel the original');
|
||||
|
||||
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map<string, string> {
|
||||
const pathMap = new Map<string, string>();
|
||||
const walk = (nodes: MailboxNode[], parentPath = '') => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) walk(node.children, fullPath);
|
||||
}
|
||||
@@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
});
|
||||
|
||||
it('should produce correct path for a single-level subfolder', () => {
|
||||
@@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
});
|
||||
|
||||
it('should produce correct path for deeply nested subfolders', () => {
|
||||
@@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub2')).toBe('Inbox/Test/Test2');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Test/Test2');
|
||||
});
|
||||
|
||||
it('should handle multiple root-level folders', () => {
|
||||
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('archive')).toBe('Archive');
|
||||
expect(paths.get('sub1')).toBe('Archive/Work');
|
||||
});
|
||||
@@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
expect(paths.has(node.id)).toBe(true);
|
||||
}
|
||||
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub2')).toBe('Inbox/Projects/Active');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Projects/Active');
|
||||
});
|
||||
|
||||
it('uses canonical INBOX even when JMAP returns a localized inbox name', () => {
|
||||
// Stalwart returns localized display names for the inbox based on the
|
||||
// user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still
|
||||
// target the IMAP-canonical "INBOX" so the message is filed correctly.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }),
|
||||
makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }),
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('host')).toBe('INBOX/Host');
|
||||
expect(paths.get('eveo')).toBe('INBOX/Host/EVEO');
|
||||
});
|
||||
|
||||
it('should preserve depth info in flattened tree', () => {
|
||||
|
||||
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => {
|
||||
// Split-DNS deployments: mail.example.com resolves to an RFC-1918 address
|
||||
// locally. With the SSRF validator off, discovery must succeed.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
issuer: 'https://mail.example.com',
|
||||
authorization_endpoint: 'http://10.0.0.5/authorize',
|
||||
token_endpoint: 'http://10.0.0.5/token',
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await discoverOAuth('https://mail.example.com');
|
||||
|
||||
expect(result?.token_endpoint).toBe('http://10.0.0.5/token');
|
||||
});
|
||||
|
||||
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { OAuthMetadata } from '../oauth/discovery';
|
||||
|
||||
// Capture status/headers from NextResponse.json (the shared config-route mock
|
||||
// drops them, but this route's behavior depends on the status code).
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number; headers?: Record<string, string> }) => ({
|
||||
json: async () => data,
|
||||
status: init?.status ?? 200,
|
||||
headers: init?.headers ?? {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
const getMetadata = vi.fn();
|
||||
const getRequiredConfig = vi.fn();
|
||||
vi.mock('@/lib/oauth/token-exchange', () => ({
|
||||
getMetadata: (...args: unknown[]) => getMetadata(...args),
|
||||
getRequiredConfig: (...args: unknown[]) => getRequiredConfig(...args),
|
||||
}));
|
||||
|
||||
const VALID_METADATA: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
};
|
||||
|
||||
function mockRequest(serverId?: string): unknown {
|
||||
return {
|
||||
nextUrl: {
|
||||
searchParams: { get: (k: string) => (k === 'server_id' ? serverId ?? null : null) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function callRoute(serverId?: string) {
|
||||
const { GET } = await import('@/app/api/auth/oauth/metadata/route');
|
||||
const res = (await GET(mockRequest(serverId) as Parameters<typeof GET>[0])) as unknown as {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
json: () => Promise<Record<string, unknown>>;
|
||||
};
|
||||
return { status: res.status, headers: res.headers, body: await res.json() };
|
||||
}
|
||||
|
||||
describe('oauth metadata route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getRequiredConfig.mockReturnValue({ discoveryUrl: 'https://auth.example.com' });
|
||||
});
|
||||
|
||||
it('returns discovered metadata for the resolved server', async () => {
|
||||
getMetadata.mockResolvedValue(VALID_METADATA);
|
||||
|
||||
const { status, body, headers } = await callRoute('server-1');
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(VALID_METADATA);
|
||||
expect(headers['Cache-Control']).toContain('max-age=600');
|
||||
expect(getMetadata).toHaveBeenCalledWith('server-1');
|
||||
});
|
||||
|
||||
it('returns 404 when OAuth is not configured', async () => {
|
||||
getRequiredConfig.mockImplementation(() => {
|
||||
throw new Error('OAuth misconfigured: OAUTH_CLIENT_ID not set');
|
||||
});
|
||||
|
||||
const { status, body } = await callRoute();
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(body).toEqual({ error: 'OAuth not configured' });
|
||||
expect(getMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 502 when discovery yields no usable endpoints', async () => {
|
||||
getMetadata.mockResolvedValue(null);
|
||||
|
||||
const { status, body } = await callRoute();
|
||||
|
||||
expect(status).toBe(502);
|
||||
expect(body).toEqual({ error: 'OAuth discovery failed' });
|
||||
});
|
||||
|
||||
it('returns 502 when discovery throws', async () => {
|
||||
getMetadata.mockRejectedValue(new Error('network down'));
|
||||
|
||||
const { status, body } = await callRoute();
|
||||
|
||||
expect(status).toBe(502);
|
||||
expect(body).toEqual({ error: 'OAuth discovery failed' });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user