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 |
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
|
|||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
# LOGIN_WEBSITE_URL=https://example.com
|
# 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
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# 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
|
# Session & Security
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -220,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
|||||||
# URL for the company website link on the login page.
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
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
|
# Extension Directory / Marketplace
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -229,6 +257,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
# your own directory (e.g. http://localhost:3001 for local development).
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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)
|
# Legacy Build-time Variables (still supported as fallback)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,96 @@
|
|||||||
# Changelog
|
# 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)
|
## 1.7.1 (2026-05-22)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# at build time, so it cannot be changed without rebuilding.
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=$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
|
# 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.
|
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||||
ARG GIT_COMMIT=unknown
|
ARG GIT_COMMIT=unknown
|
||||||
|
|||||||
+14
-8
@@ -7,7 +7,10 @@
|
|||||||
- Unified mailbox view across all connected accounts
|
- Unified mailbox view across all connected accounts
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- 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
|
- 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
|
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
- Batch operations – multi-select, archive, delete, move, tag
|
- Batch operations – multi-select, archive, delete, move, tag
|
||||||
- Archive modes – direct, by year, or by month
|
- Archive modes – direct, by year, or by month
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
- Auto-generated birthday calendar from contacts
|
- Auto-generated birthday calendar from contacts
|
||||||
- Virtual locations (video conference URLs) as first-class event fields
|
- Virtual locations (video conference URLs) as first-class event fields
|
||||||
- Task management with due dates, priority, and completion status
|
- 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
|
- Week numbers, event hover preview, notifications with sound picker
|
||||||
- Real-time sync via JMAP push
|
- Real-time sync via JMAP push
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@
|
|||||||
## Filters & Templates
|
## Filters & Templates
|
||||||
|
|
||||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
- 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
|
- Preserves rules authored in other clients
|
||||||
- Raw Sieve editor with syntax validation
|
- Raw Sieve editor with syntax validation
|
||||||
- Vacation responder with date range scheduling
|
- Vacation responder with date range scheduling
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
|
|
||||||
## Files
|
## 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
|
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||||
- Dynamic upload limits based on server configuration
|
- Dynamic upload limits based on server configuration
|
||||||
- Grid and list views with sorting by name, size, or date
|
- 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
|
- External content blocked by default, with a trusted senders list
|
||||||
- HTML sanitization via DOMPurify
|
- HTML sanitization via DOMPurify
|
||||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
- 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
|
- 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
|
- TOTP two-factor authentication
|
||||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
- 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
|
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||||
- Dark and light themes with intelligent email color transformation
|
- Dark and light themes with intelligent email color transformation
|
||||||
|
- 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
|
- Responsive desktop, tablet, and mobile layouts
|
||||||
- Full keyboard navigation
|
- Full keyboard navigation
|
||||||
- Drag-and-drop email organization and tag assignment
|
- Drag-and-drop email organization and tag assignment
|
||||||
@@ -98,7 +102,7 @@
|
|||||||
|
|
||||||
## Internationalization
|
## 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`.
|
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
|
- 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
|
- 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)
|
- 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`
|
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
- 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
|
## 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
|
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||||
- Structured logging (`text` or `json`) with category-based levels
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
- 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)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
[](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
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
Plus the infrastructure around them: 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)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
|
|||||||
LOGIN_WEBSITE_URL=https://example.com
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
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>
|
</details>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
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 { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -96,7 +96,7 @@ function OAuthCallbackInner() {
|
|||||||
// the refresh-token cookie write for the same reason.
|
// the refresh-token cookie write for the same reason.
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/sso/complete", {
|
const res = await apiFetch("/api/auth/sso/complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
|||||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||||
@@ -97,6 +98,9 @@ export default function CalendarPage() {
|
|||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
|
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
@@ -1030,10 +1034,47 @@ export default function CalendarPage() {
|
|||||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
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(() => {
|
const allCalendars = useMemo(() => {
|
||||||
if (!showBirthdayCalendar) return calendars;
|
if (!showBirthdayCalendar) return displayCalendars;
|
||||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() => {
|
const visibleEvents = useMemo(() => {
|
||||||
const filtered = events.filter((e) => {
|
const filtered = events.filter((e) => {
|
||||||
@@ -1219,7 +1260,7 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
<TaskListView
|
<TaskListView
|
||||||
tasks={taskStore.tasks}
|
tasks={taskStore.tasks}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
filter={taskStore.filter}
|
filter={taskStore.filter}
|
||||||
showCompleted={taskStore.showCompleted}
|
showCompleted={taskStore.showCompleted}
|
||||||
@@ -1317,8 +1358,21 @@ export default function CalendarPage() {
|
|||||||
updateSetting('birthdayCalendarColor', color);
|
updateSetting('birthdayCalendarColor', color);
|
||||||
return;
|
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 });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : 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}
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
onCreateEvent={(cal: Calendar) => {
|
onCreateEvent={(cal: Calendar) => {
|
||||||
setDefaultCalendarIdForCreate(cal.id);
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
@@ -1389,7 +1443,7 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
enableCalendarTasks={enableCalendarTasks}
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
@@ -1419,7 +1473,7 @@ export default function CalendarPage() {
|
|||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1442,7 +1496,7 @@ export default function CalendarPage() {
|
|||||||
<TaskModal
|
<TaskModal
|
||||||
key={editTask?.id ?? 'new-task'}
|
key={editTask?.id ?? 'new-task'}
|
||||||
task={editTask}
|
task={editTask}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
onSave={handleSaveTask}
|
onSave={handleSaveTask}
|
||||||
onDelete={handleDeleteTask}
|
onDelete={handleDeleteTask}
|
||||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||||
@@ -1529,7 +1583,7 @@ export default function CalendarPage() {
|
|||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||||
anchorRect={detailAnchorRect}
|
anchorRect={detailAnchorRect}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
@@ -1549,7 +1603,7 @@ export default function CalendarPage() {
|
|||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1566,7 +1620,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{showImportModal && client && (
|
{showImportModal && client && (
|
||||||
<ICalImportModal
|
<ICalImportModal
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
client={client}
|
client={client}
|
||||||
initialUrl={pendingSubscription?.url}
|
initialUrl={pendingSubscription?.url}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
|||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -48,9 +48,11 @@ export default function FilesPage() {
|
|||||||
supportsFiles,
|
supportsFiles,
|
||||||
selectedResources,
|
selectedResources,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
|
migrationProgress,
|
||||||
clipboard,
|
clipboard,
|
||||||
initClient,
|
initClient,
|
||||||
checkSupport,
|
checkSupport,
|
||||||
|
migrateLegacyFlatNodes,
|
||||||
navigate,
|
navigate,
|
||||||
navigateByPath,
|
navigateByPath,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -160,13 +162,16 @@ export default function FilesPage() {
|
|||||||
const storeClient = useFileStore(s => s.client);
|
const storeClient = useFileStore(s => s.client);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storeClient && supportsFiles === null) {
|
if (storeClient && supportsFiles === null) {
|
||||||
checkSupport().then((supported) => {
|
checkSupport().then(async (supported) => {
|
||||||
if (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);
|
navigate(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
|
||||||
|
|
||||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||||
// Pro shell only: the Account breadcrumb segment signals "go to this
|
// 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} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
|||||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
|
|||||||
{children}
|
{children}
|
||||||
<PluginDialogHost />
|
<PluginDialogHost />
|
||||||
<PluginConsentDialog />
|
<PluginConsentDialog />
|
||||||
|
<PWAInstallPrompt />
|
||||||
</ProtocolLaunchHandlerProvider>
|
</ProtocolLaunchHandlerProvider>
|
||||||
</TourProvider>
|
</TourProvider>
|
||||||
</EmbeddedBridgeProvider>
|
</EmbeddedBridgeProvider>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||||
@@ -329,16 +329,27 @@ export default function LoginPage() {
|
|||||||
if (!oauthEnabled || !serverUrl) return;
|
if (!oauthEnabled || !serverUrl) return;
|
||||||
setOauthDiscoveryDone(false);
|
setOauthDiscoveryDone(false);
|
||||||
setOauthMetadata(null);
|
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) => {
|
.then((metadata) => {
|
||||||
setOauthMetadata(metadata);
|
setOauthMetadata(metadata);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
setOauthDiscoveryDone(true);
|
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
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
const ssoError = searchParams.get("sso_error");
|
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="px-8 pt-12 pb-4 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
<img
|
<img
|
||||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-20 max-h-20 object-contain"
|
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="px-8 pt-10 pb-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||||
<img
|
<img
|
||||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-16 max-h-16 object-contain"
|
className="max-w-16 max-h-16 object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+479
-156
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 { 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 { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
import MailPage from "@/app/(main)/[locale]/page";
|
import MailPage from "@/app/(main)/[locale]/page";
|
||||||
import CalendarPage from "@/app/(main)/[locale]/calendar/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
|
// enabled it. If either precondition stops holding, hand the user back
|
||||||
// to the standard shell.
|
// to the standard shell.
|
||||||
if (isMobile || isTablet || !proInterface) {
|
if (isMobile || isTablet || !proInterface) {
|
||||||
window.location.replace("/");
|
window.location.replace(`${getPathPrefix()}/`);
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
Info,
|
Info,
|
||||||
Bug,
|
Bug,
|
||||||
|
SwatchBook,
|
||||||
|
Download,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -59,6 +61,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
|||||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||||
|
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||||
@@ -90,6 +93,7 @@ type Tab =
|
|||||||
| 'layout'
|
| 'layout'
|
||||||
| 'reading'
|
| 'reading'
|
||||||
| 'composing'
|
| 'composing'
|
||||||
|
| 'downloads'
|
||||||
| 'identities'
|
| 'identities'
|
||||||
| 'vacation'
|
| 'vacation'
|
||||||
| 'filters'
|
| 'filters'
|
||||||
@@ -126,6 +130,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
layout: LayoutGrid,
|
layout: LayoutGrid,
|
||||||
reading: BookOpen,
|
reading: BookOpen,
|
||||||
composing: PenLine,
|
composing: PenLine,
|
||||||
|
downloads: Download,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
vacation: PalmtreeIcon,
|
vacation: PalmtreeIcon,
|
||||||
filters: Filter,
|
filters: Filter,
|
||||||
@@ -141,7 +146,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
protocol_handlers: LinkIcon,
|
protocol_handlers: LinkIcon,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
about_data: Info,
|
about_data: Info,
|
||||||
themes: Palette,
|
themes: SwatchBook,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
@@ -202,6 +207,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.email_behavior.signature_position',
|
'settings.email_behavior.signature_position',
|
||||||
'settings.email_behavior.sub_address_delimiter',
|
'settings.email_behavior.sub_address_delimiter',
|
||||||
],
|
],
|
||||||
|
downloads: ['settings.downloads'],
|
||||||
identities: ['settings.identities'],
|
identities: ['settings.identities'],
|
||||||
vacation: ['settings.vacation'],
|
vacation: ['settings.vacation'],
|
||||||
filters: ['settings.filters'],
|
filters: ['settings.filters'],
|
||||||
@@ -236,6 +242,7 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||||
composing: 'editor signature plain text reply forward draft compose',
|
composing: 'editor signature plain text reply forward draft compose',
|
||||||
|
downloads: 'download filename template eml attachment save export',
|
||||||
identities: 'from address signature email',
|
identities: 'from address signature email',
|
||||||
vacation: 'auto reply away out of office holiday responder',
|
vacation: 'auto reply away out of office holiday responder',
|
||||||
filters: 'sieve rules block junk forward',
|
filters: 'sieve rules block junk forward',
|
||||||
@@ -329,6 +336,14 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
|||||||
|
|
||||||
function readPersistedTab(): Tab {
|
function readPersistedTab(): Tab {
|
||||||
try {
|
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');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
if (saved in LEGACY_TAB_MAP) {
|
if (saved in LEGACY_TAB_MAP) {
|
||||||
@@ -354,6 +369,11 @@ export default function SettingsPage() {
|
|||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
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 [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||||
@@ -576,10 +596,12 @@ export default function SettingsPage() {
|
|||||||
// Appearance
|
// Appearance
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, 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
|
// Mail
|
||||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, 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' },
|
{ 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 }] : []),
|
...(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 }] : []),
|
...(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
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: '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('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 }] : []),
|
...(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 === 'layout' && <LayoutSettings />}
|
||||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||||
|
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
@@ -925,7 +947,7 @@ export default function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={tab.id}>
|
<div key={tab.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'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
|
effectiveActiveTab === tab.id
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
|||||||
|
|
||||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||||
|
const lastEmittedRef = useRef(value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
if (value === lastEmittedRef.current) return;
|
||||||
// initial load). Skip when drafts already represent the same array to avoid
|
setDrafts(value.map(entryToDraft))
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
function commit(next: RowDraft[]) {
|
function commit(next: RowDraft[]) {
|
||||||
setDrafts(next);
|
setDrafts(next);
|
||||||
const entries: JmapServerEntry[] = [];
|
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||||
for (const d of next) {
|
lastEmittedRef.current = entries;
|
||||||
const e = draftToEntry(d);
|
|
||||||
if (e) entries.push(e);
|
|
||||||
}
|
|
||||||
onChange(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 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 Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||||
|
<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 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:..." />
|
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
import {
|
||||||
|
BRANDING_OVERRIDE_KEYS,
|
||||||
|
parseDomainBranding,
|
||||||
|
type BrandingOverrideKey,
|
||||||
|
type DomainBrandingEntry,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
@@ -16,42 +22,67 @@ const IMAGE_FIELDS = [
|
|||||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
{ 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: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const TEXT_FIELDS = [
|
const TEXT_FIELDS = [
|
||||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const PWA_IMAGE_FIELDS = [
|
const PWA_IMAGE_FIELDS = [
|
||||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
{ 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 = [
|
const PWA_TEXT_FIELDS = [
|
||||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const PWA_COLOR_FIELDS = [
|
const PWA_COLOR_FIELDS = [
|
||||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||||
{ key: 'pwaBackgroundColor', label: 'Background 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() {
|
export function BrandingTab() {
|
||||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: 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>>({});
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
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() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await apiFetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
@@ -59,29 +90,81 @@ export function BrandingTab() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedEntry(): DomainBrandingEntry | null {
|
||||||
|
if (!selectedHost) return null;
|
||||||
|
return domainEntries.find(e => e.host === selectedHost) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function handleChange(key: string, value: string) {
|
function handleChange(key: string, value: string) {
|
||||||
setEdits(prev => ({ ...prev, [key]: value }));
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentValue(key: string): string {
|
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) ?? '';
|
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() {
|
async function handleSave() {
|
||||||
if (Object.keys(edits).length === 0) return;
|
if (Object.keys(edits).length === 0) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
|
const payload = selectedHost
|
||||||
|
? { domainBranding: buildUpdatedDomainBranding(edits) }
|
||||||
|
: edits;
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
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({});
|
setEdits({});
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
} else {
|
} else {
|
||||||
@@ -92,12 +175,20 @@ export function BrandingTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(slot: string, file: File) {
|
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);
|
setUploading(slot);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('slot', slot);
|
formData.append('slot', slot);
|
||||||
|
if (selectedHost) formData.append('host', selectedHost);
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -112,10 +203,9 @@ export function BrandingTab() {
|
|||||||
delete next[slot];
|
delete next[slot];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setConfig(prev => ({
|
// Refresh from server so domainBranding entries reflect the upload.
|
||||||
...prev,
|
await fetchConfig();
|
||||||
[slot]: { value: data.url, source: 'admin' },
|
void data;
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
@@ -126,10 +216,13 @@ export function BrandingTab() {
|
|||||||
async function handleDeleteUpload(slot: string) {
|
async function handleDeleteUpload(slot: string) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
|
const body: { slot: string; host?: string } = { slot };
|
||||||
|
if (selectedHost) body.host = selectedHost;
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slot }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -147,6 +240,25 @@ export function BrandingTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
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', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -162,12 +274,71 @@ export function BrandingTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUploadedFile = (key: string): boolean => {
|
async function handleAddDomain() {
|
||||||
const val = currentValue(key);
|
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
|
||||||
return val.startsWith('/api/admin/branding/');
|
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 hasEdits = Object.keys(edits).length > 0;
|
||||||
|
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
@@ -192,6 +363,102 @@ export function BrandingTab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{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'}`}>
|
<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}
|
{message.text}
|
||||||
@@ -209,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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<label className="text-sm text-foreground">{field.label}</label>
|
<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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +487,7 @@ export function BrandingTab() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={currentValue(field.key)}
|
value={currentValue(field.key)}
|
||||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
placeholder="Enter URL or upload a file"
|
placeholder={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"
|
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -236,9 +503,9 @@ export function BrandingTab() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
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"
|
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" />}
|
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -251,7 +518,7 @@ export function BrandingTab() {
|
|||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</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">
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -263,7 +530,7 @@ export function BrandingTab() {
|
|||||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
<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">
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
<img
|
<img
|
||||||
src={currentValue(field.key)}
|
src={withBasePath(currentValue(field.key))}
|
||||||
alt={field.label}
|
alt={field.label}
|
||||||
className="max-h-6 max-w-[200px] object-contain"
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
@@ -287,9 +554,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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<label className="text-sm text-foreground">{field.label}</label>
|
<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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +565,7 @@ export function BrandingTab() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={currentValue(field.key)}
|
value={currentValue(field.key)}
|
||||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
placeholder="Enter URL or upload a file"
|
placeholder={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"
|
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -314,9 +581,9 @@ export function BrandingTab() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
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"
|
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" />}
|
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -329,7 +596,7 @@ export function BrandingTab() {
|
|||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</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">
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -341,7 +608,7 @@ export function BrandingTab() {
|
|||||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
<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">
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
<img
|
<img
|
||||||
src={currentValue(field.key)}
|
src={withBasePath(currentValue(field.key))}
|
||||||
alt={field.label}
|
alt={field.label}
|
||||||
className="max-h-6 max-w-[200px] object-contain"
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
@@ -355,8 +622,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 key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<label className="text-sm text-foreground">{field.label}</label>
|
<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">admin</span>
|
<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>
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
@@ -367,7 +636,7 @@ export function BrandingTab() {
|
|||||||
placeholder={field.placeholder}
|
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"
|
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">
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -381,8 +650,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 key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<label className="text-sm text-foreground">{field.label}</label>
|
<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">admin</span>
|
<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>
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
@@ -400,7 +671,7 @@ export function BrandingTab() {
|
|||||||
placeholder={field.defaultValue}
|
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"
|
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
{config[field.key]?.source === 'admin' && (
|
{isOverriddenInScope(field.key) && (
|
||||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -421,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 key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<label className="text-sm text-foreground">{field.label}</label>
|
<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">admin</span>
|
<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>
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
@@ -433,7 +706,7 @@ export function BrandingTab() {
|
|||||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
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"
|
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">
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
|
|||||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
@@ -74,6 +74,18 @@ export function PolicyTab() {
|
|||||||
setMessage(null);
|
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) {
|
function toggleLocked(settingKey: string) {
|
||||||
setPolicy(prev => {
|
setPolicy(prev => {
|
||||||
const existing = prev.restrictions[settingKey] || {};
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
@@ -183,6 +195,34 @@ export function PolicyTab() {
|
|||||||
</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">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 => (
|
{categories.map(category => (
|
||||||
<div key={category} className="border border-border rounded-lg">
|
<div key={category} className="border border-border rounded-lg">
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
|||||||
@@ -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="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="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>
|
||||||
|
|
||||||
<SettingsSection title="JMAP Servers (multi-server)">
|
<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 { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
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
|
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||||
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark'
|
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||||
|
|
||||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+29
-6
@@ -1,12 +1,25 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
import { locales } from "@/i18n/routing";
|
||||||
import "../globals.css";
|
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({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -26,10 +39,21 @@ export const viewport: Viewport = {
|
|||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
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 {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
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: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "black-translucent",
|
statusBarStyle: "black-translucent",
|
||||||
@@ -38,7 +62,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
formatDetection: {
|
formatDetection: {
|
||||||
telephone: false,
|
telephone: false,
|
||||||
},
|
},
|
||||||
icons: { icon: faviconUrl },
|
icons: { icon: withBasePath(faviconUrl) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +71,7 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const locale = await getLocale();
|
const locale = await resolveRequestLocale();
|
||||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
@@ -91,7 +115,6 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
{children}
|
{children}
|
||||||
<PWAInstallPrompt />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ export default function SetupWizardPage() {
|
|||||||
// edge cases that swallow client-side replaces after the
|
// edge cases that swallow client-side replaces after the
|
||||||
// setupComplete flag flips.
|
// setupComplete flag flips.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.assign('/admin/login');
|
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -340,13 +340,13 @@ function CompletedScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6 space-y-2">
|
||||||
<a
|
<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"
|
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
|
Sign in to admin dashboard
|
||||||
</a>
|
</a>
|
||||||
<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"
|
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
|
Open webmail login
|
||||||
@@ -422,13 +422,13 @@ function AlreadyConfiguredScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6 space-y-2">
|
||||||
<a
|
<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"
|
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
|
Sign in to admin dashboard
|
||||||
</a>
|
</a>
|
||||||
<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"
|
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
|
Open webmail login
|
||||||
@@ -1329,7 +1329,7 @@ function BrandingAsset({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{value ? (
|
{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>
|
<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 { Metadata } from 'next';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
|
||||||
import '../globals.css';
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||||
variable: '--font-geist-sans',
|
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||||
subsets: ['latin'],
|
// 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:
|
||||||
const geistMono = Geist_Mono({
|
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||||
variable: '--font-geist-mono',
|
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||||
subsets: ['latin'],
|
// so the sandbox never fetches same-origin assets itself.
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Plugin sandbox',
|
title: 'Plugin sandbox',
|
||||||
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
|
|||||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+159
-32
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { getConfigDir } from '@/lib/admin/paths';
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
import {
|
||||||
|
parseDomainBranding,
|
||||||
|
type DomainBrandingEntry,
|
||||||
|
type BrandingOverrideKey,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
import { logger } from '@/lib/logger';
|
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 { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -21,27 +26,99 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
'image/vnd.microsoft.icon',
|
'image/vnd.microsoft.icon',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
type UploadSlot = BrandingOverrideKey;
|
||||||
|
|
||||||
/** Slots that correspond to branding config keys */
|
/** Slots that correspond to branding config keys */
|
||||||
const VALID_SLOTS = new Set([
|
const VALID_SLOTS = new Set<UploadSlot>([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
'pwaIconUrl',
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'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 {
|
function sanitizeFilename(name: string): string {
|
||||||
// Strip directory traversal, keep only safe chars
|
// Strip directory traversal, keep only safe chars
|
||||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
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
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
* - 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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -52,15 +129,24 @@ export async function POST(request: NextRequest) {
|
|||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
const slot = formData.get('slot') as string | null;
|
const slot = formData.get('slot') as string | null;
|
||||||
|
const rawHost = (formData.get('host') as string | null) ?? '';
|
||||||
|
|
||||||
if (!file || !slot) {
|
if (!file || !slot) {
|
||||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
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 });
|
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) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -72,34 +158,51 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine extension from mime type
|
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||||
const extMap: Record<string, string> = {
|
const safeName = host
|
||||||
'image/svg+xml': '.svg',
|
? domainAssetName(host, slot as BrandingOverrideKey, ext)
|
||||||
'image/png': '.png',
|
: sanitizeFilename(`${slot}${ext}`);
|
||||||
'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 filePath = path.join(getBrandingDir(), safeName);
|
const filePath = path.join(getBrandingDir(), safeName);
|
||||||
|
|
||||||
// Ensure branding directory exists
|
|
||||||
if (!existsSync(getBrandingDir())) {
|
if (!existsSync(getBrandingDir())) {
|
||||||
await mkdir(getBrandingDir(), { recursive: true });
|
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());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await writeFile(filePath, buffer);
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
// Update config to point to the served URL
|
|
||||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
await configManager.ensureLoaded();
|
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 });
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -111,7 +214,11 @@ export async function POST(request: NextRequest) {
|
|||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
* 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) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -119,28 +226,48 @@ export async function DELETE(request: NextRequest) {
|
|||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
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 });
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and remove matching files for this slot
|
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
if (host && !EXACT_HOST_RE.test(host)) {
|
||||||
|
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = getBrandingDir();
|
||||||
let removed = false;
|
let removed = false;
|
||||||
for (const ext of possibleExts) {
|
if (host) {
|
||||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||||
if (existsSync(filePath)) {
|
for (const f of allFiles) {
|
||||||
await unlink(filePath);
|
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
|
||||||
removed = true;
|
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.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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Strings that count as "no real secret configured" - used so the dashboard
|
// 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;
|
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
|
// Get old values for audit
|
||||||
const oldValues: Record<string, unknown> = {};
|
const oldValues: Record<string, unknown> = {};
|
||||||
for (const key of Object.keys(updates)) {
|
for (const key of Object.keys(updates)) {
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(manifest.locales && typeof manifest.locales === 'object'
|
||||||
|
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||||
|
: {}),
|
||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { 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 { logger } from '@/lib/logger';
|
||||||
import { encryptPayload } from '@/lib/auth/crypto';
|
import { encryptPayload } from '@/lib/auth/crypto';
|
||||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
|
||||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||||
|
|
||||||
if (!metadata?.authorization_endpoint) {
|
if (!metadata?.authorization_endpoint) {
|
||||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
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
|
// 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;
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
// 2. Try common Stalwart token endpoint paths directly
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let resolvedServerId: string | null = null;
|
let resolvedServerId: string | null = null;
|
||||||
|
let adminTrusted = false;
|
||||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||||
|
|
||||||
if (matchedEntry) {
|
if (matchedEntry) {
|
||||||
upstreamUrl = matchedEntry.url;
|
upstreamUrl = matchedEntry.url;
|
||||||
resolvedServerId = matchedEntry.id;
|
resolvedServerId = matchedEntry.id;
|
||||||
|
adminTrusted = true;
|
||||||
} else if (configuredServerUrl) {
|
} else if (configuredServerUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = configuredServerUrl;
|
||||||
|
adminTrusted = true;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
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 });
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||||
if (!tokenEndpoint) {
|
if (!tokenEndpoint) {
|
||||||
logger.warn('TOTP token exchange: no token endpoint found');
|
logger.warn('TOTP token exchange: no token endpoint found');
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
return 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 { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
type BrandingOverrideKey,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
|
|||||||
* post-build configuration for Docker deployments.
|
* post-build configuration for Docker deployments.
|
||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 1. Admin dashboard overrides (data/admin/config.json)
|
* 1. Per-domain branding override (admin-configured, matched on request host)
|
||||||
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
* 2. Admin dashboard overrides (data/admin/config.json)
|
||||||
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||||
* 4. Default values
|
* 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');
|
logger.debug('Config requested');
|
||||||
await configManager.ensureLoaded();
|
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 jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||||
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
appName,
|
{
|
||||||
jmapServerUrl,
|
appName,
|
||||||
oauthEnabled,
|
jmapServerUrl,
|
||||||
oauthOnly,
|
oauthEnabled,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthOnly,
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthScopes: getOauthScopes(),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: hasSessionSecret(),
|
oauthScopes: getOauthScopes(),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
rememberMeEnabled: hasSessionSecret(),
|
||||||
stalwartFeaturesEnabled,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||||
devMode: configManager.get<boolean>('devMode', false),
|
stalwartFeaturesEnabled,
|
||||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
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 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
|
// Mailboxes
|
||||||
@@ -721,7 +723,18 @@ const emails: MockEmail[] = [
|
|||||||
// Identities
|
// 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',
|
id: 'identity-001',
|
||||||
name: 'Dev User',
|
name: 'Dev User',
|
||||||
@@ -1533,6 +1546,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
|||||||
bodyValues: {},
|
bodyValues: {},
|
||||||
};
|
};
|
||||||
emails.unshift(newEmail);
|
emails.unshift(newEmail);
|
||||||
|
emailCreationIds.set(key, newId);
|
||||||
created[key] = { id: newId };
|
created[key] = { id: newId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1559,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
|||||||
|
|
||||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||||
const created: Record<string, { id: string }> = {};
|
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) {
|
if (create) {
|
||||||
for (const key of Object.keys(create)) {
|
for (const [key, data] of Object.entries(create)) {
|
||||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
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 {
|
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];
|
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
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];
|
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 {
|
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/get': handleIdentityGet,
|
||||||
'Identity/set': handleIdentitySet,
|
'Identity/set': handleIdentitySet,
|
||||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||||
|
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||||
|
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||||
'Quota/get': handleQuotaGet,
|
'Quota/get': handleQuotaGet,
|
||||||
'VacationResponse/get': handleVacationResponseGet,
|
'VacationResponse/get': handleVacationResponseGet,
|
||||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
'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,
|
isReadOnly: false,
|
||||||
accountCapabilities: {
|
accountCapabilities: {
|
||||||
'urn:ietf:params:jmap:mail': {},
|
'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:quota': {},
|
||||||
'urn:ietf:params:jmap:vacationresponse': {},
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
'urn:ietf:params:jmap:contacts': {},
|
'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_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
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
|
// 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;
|
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)
|
// Check negative cache (domains known to have no favicon)
|
||||||
const neg = negativeCache.get(normalizedDomain);
|
const neg = negativeCache.get(normalizedDomain);
|
||||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
evictNegativeOldest();
|
evictNegativeOldest();
|
||||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||||
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (data.byteLength < 10) {
|
if (data.byteLength < 10) {
|
||||||
evictNegativeOldest();
|
evictNegativeOldest();
|
||||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export async function GET() {
|
|||||||
// Per-user settings schema, captured from the manifest at upload/load
|
// Per-user settings schema, captured from the manifest at upload/load
|
||||||
// time so the client can render the settings UI without re-parsing.
|
// time so the client can render the settings UI without re-parsing.
|
||||||
settingsSchema: p.settingsSchema,
|
settingsSchema: p.settingsSchema,
|
||||||
|
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||||
|
// strings via api.i18n.t().
|
||||||
|
locales: p.locales,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Only serve enabled themes
|
// Only serve enabled themes
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import path from 'node:path';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { getConfigDir } from '@/lib/admin/paths';
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
|
|
||||||
const VALID_SIZES = new Set([192, 512]);
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ size: string }> }
|
{ params }: { params: Promise<{ size: string }> }
|
||||||
) {
|
) {
|
||||||
const { size: sizeParam } = await params;
|
const { size: sizeParam } = await params;
|
||||||
@@ -44,8 +49,15 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
|
const host = pickRequestHost(req);
|
||||||
|
const domainOverrides = matchDomainBranding(
|
||||||
|
host,
|
||||||
|
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||||
|
);
|
||||||
const sources = configManager.getAllWithSources();
|
const sources = configManager.getAllWithSources();
|
||||||
const iconUrl =
|
const iconUrl =
|
||||||
|
domainOverrides.pwaIconUrl ||
|
||||||
|
domainOverrides.faviconUrl ||
|
||||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||||
if (!iconUrl) {
|
if (!iconUrl) {
|
||||||
@@ -55,6 +67,7 @@ export async function GET(
|
|||||||
const pngHeaders = {
|
const pngHeaders = {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
'Cache-Control': 'public, max-age=86400',
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
Vary: 'Host, X-Forwarded-Host',
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheKey = `${size}|${iconUrl}`;
|
const cacheKey = `${size}|${iconUrl}`;
|
||||||
|
|||||||
@@ -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([
|
const VALID_SLOTS = new Set([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'loginLogoDarkUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
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 {
|
.dark {
|
||||||
--color-border: #262626;
|
--color-border: rgba(128, 128, 128, 0.3);
|
||||||
--color-input: #262626;
|
--color-input: #262626;
|
||||||
--color-ring: #d4d4d4;
|
--color-ring: #d4d4d4;
|
||||||
--color-background: #0a0a0a;
|
--color-background: #0a0a0a;
|
||||||
@@ -242,8 +242,8 @@ body {
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.email-content-text {
|
.email-content-text {
|
||||||
padding-left: 0;
|
padding-left: 0.75rem;
|
||||||
padding-right: 0;
|
padding-right: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-14
@@ -1,5 +1,12 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
type BrandingOverrideKey,
|
||||||
|
} from "@/lib/admin/domain-branding";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
|
|||||||
export default async function manifest(): Promise<ExtendedManifest> {
|
export default async function manifest(): Promise<ExtendedManifest> {
|
||||||
await configManager.ensureLoaded();
|
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 =
|
const appName =
|
||||||
configManager.get<string>("appName") ||
|
branded<string>("appName", "") ||
|
||||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
"Bulwark Webmail";
|
"Bulwark Webmail";
|
||||||
|
|
||||||
const shortName = configManager.get<string>("appShortName") || appName;
|
const shortName = branded<string>("appShortName", "") || appName;
|
||||||
const description =
|
const description =
|
||||||
configManager.get<string>("appDescription") ||
|
branded<string>("appDescription", "") ||
|
||||||
"A modern webmail client built for Stalwart Mail Server";
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
|
||||||
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
|
||||||
|
|
||||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
|
||||||
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
// env var, or per-domain override), serve dynamically resized PNGs via
|
||||||
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
|
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
|
||||||
// are the built-in placeholder paths and not real custom icons.
|
// sources marked "default" are the built-in placeholder paths and not
|
||||||
|
// real custom icons.
|
||||||
const sources = configManager.getAllWithSources();
|
const sources = configManager.getAllWithSources();
|
||||||
const hasCustomIcon =
|
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
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
? [
|
? [
|
||||||
@@ -73,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
|||||||
background_color: backgroundColor,
|
background_color: backgroundColor,
|
||||||
icons,
|
icons,
|
||||||
categories: ["productivity"],
|
categories: ["productivity"],
|
||||||
screenshots: [
|
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
// 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_handlers: [
|
||||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onResetColor?: (calendar: Calendar) => void;
|
||||||
onShareCalendar?: (calendar: Calendar) => void;
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
onCreateEvent?: (calendar: Calendar) => void;
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
onClearCalendar?: (calendar: Calendar) => void;
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onResetColor,
|
||||||
onShareCalendar,
|
onShareCalendar,
|
||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
onClearCalendar,
|
onClearCalendar,
|
||||||
@@ -94,6 +97,7 @@ export function CalendarSidebarPanel({
|
|||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
const tasks = useTaskStore((s) => s.tasks);
|
const tasks = useTaskStore((s) => s.tasks);
|
||||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||||
@@ -303,9 +307,11 @@ export function CalendarSidebarPanel({
|
|||||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
const canChangeColor = !!onColorChange;
|
const canChangeColor = !!onColorChange;
|
||||||
|
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
|
const canResetColor = !!onResetColor && hasColorOverride;
|
||||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
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";
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -335,6 +341,13 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
{canResetColor && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Shuffle}
|
||||||
|
label={tMgmt('random_color')}
|
||||||
|
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showSeparator && <ContextMenuSeparator />}
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
{canClear && (
|
{canClear && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
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));
|
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 calendarName = calendar?.name || "";
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endTime = getEventEndDate(event);
|
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 ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent) => {
|
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",
|
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
continuesAfter && "rounded-r-sm",
|
continuesAfter && "rounded-r-sm",
|
||||||
continuesBefore && "-ml-0.5",
|
|
||||||
continuesAfter && "pr-2",
|
continuesAfter && "pr-2",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||||
Pencil, Trash2, Copy, Send, Check,
|
Pencil, Trash2, Copy, Send, Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, isSameDay, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -143,6 +143,8 @@ export function EventDetailPopover({
|
|||||||
const startDate = getEventStartDate(event);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = getEventEndDate(event);
|
const endDate = getEventEndDate(event);
|
||||||
|
const displayEndDate = getEventDisplayEndDate(event);
|
||||||
|
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
@@ -331,16 +333,50 @@ export function EventDetailPopover({
|
|||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-foreground">
|
{isMultiDay ? (
|
||||||
{formatEventDate(startDate)}
|
event.showWithoutTime ? (
|
||||||
</span>
|
<>
|
||||||
{event.showWithoutTime ? (
|
<div className="font-medium text-foreground">
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
{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="font-medium text-foreground">
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
{formatEventDate(startDate)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
@@ -621,14 +621,42 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm">
|
{(() => {
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
const displayEnd = getEventDisplayEndDate(event);
|
||||||
{!event.showWithoutTime && (
|
const multiDay = !isSameDay(startD, displayEnd);
|
||||||
<span className="text-muted-foreground ml-2">
|
if (multiDay && event.showWithoutTime) {
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
return (
|
||||||
</span>
|
<div className="text-sm">
|
||||||
)}
|
<div className="font-medium">{formatEventDate(startD)} –</div>
|
||||||
</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 && (
|
{event.description && (
|
||||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
<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">
|
<div className="flex items-start gap-2.5">
|
||||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-foreground">
|
{(() => {
|
||||||
{formatEventDate(startD)}
|
const displayEnd = getEventDisplayEndDate(event);
|
||||||
</span>
|
const multiDay = !isSameDay(startD, displayEnd);
|
||||||
{event.showWithoutTime ? (
|
if (multiDay && event.showWithoutTime) {
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
return (
|
||||||
) : (
|
<>
|
||||||
<div className="text-muted-foreground">
|
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, 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 { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { 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 { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -23,6 +26,7 @@ import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builde
|
|||||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useTemplateStore } from "@/stores/template-store";
|
import { useTemplateStore } from "@/stores/template-store";
|
||||||
@@ -88,7 +92,10 @@ interface EmailComposerProps {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
|
delayedUntil?: string;
|
||||||
|
requestReadReceipt?: boolean;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
onScheduledSendCreated?: () => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
onSaveState?: (data: ComposerDraftData) => void;
|
onSaveState?: (data: ComposerDraftData) => void;
|
||||||
@@ -168,8 +175,21 @@ function buildEmbeddedSignatureHtml(
|
|||||||
return '';
|
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({
|
export function EmailComposer({
|
||||||
onSend,
|
onSend,
|
||||||
|
onScheduledSendCreated,
|
||||||
onClose,
|
onClose,
|
||||||
onDiscardDraft,
|
onDiscardDraft,
|
||||||
onSaveState,
|
onSaveState,
|
||||||
@@ -181,14 +201,17 @@ export function EmailComposer({
|
|||||||
}: EmailComposerProps) {
|
}: EmailComposerProps) {
|
||||||
const t = useTranslations('email_composer');
|
const t = useTranslations('email_composer');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
const tQuote = useTranslations('quote_header');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
|
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||||
|
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||||
// Pro shell: surface identities from every connected account, grouped
|
// Pro shell: surface identities from every connected account, grouped
|
||||||
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
||||||
@@ -212,10 +235,16 @@ export function EmailComposer({
|
|||||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||||
? initialCurrentIdentityForSig
|
? initialCurrentIdentityForSig
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||||
const shouldEmbedSignatureAboveQuote =
|
const shouldEmbedSignatureAboveQuote =
|
||||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
signaturePosition === 'above_quote' &&
|
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
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
@@ -243,11 +272,9 @@ export function EmailComposer({
|
|||||||
const getInitialSubject = () => {
|
const getInitialSubject = () => {
|
||||||
if (!replyTo?.subject) return "";
|
if (!replyTo?.subject) return "";
|
||||||
if (mode === 'forward') {
|
if (mode === 'forward') {
|
||||||
const fwdPrefix = t('prefix.forward');
|
return buildForwardSubject(replyTo.subject, t('prefix.forward'));
|
||||||
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
|
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
const rePrefix = t('prefix.reply');
|
return buildReplySubject(replyTo.subject, t('prefix.reply'));
|
||||||
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
@@ -256,11 +283,26 @@ export function EmailComposer({
|
|||||||
if (plainTextMode) {
|
if (plainTextMode) {
|
||||||
// Plain text mode: produce plain text body with no HTML
|
// Plain text mode: produce plain text body with no HTML
|
||||||
const prefix = initialDraftText || "";
|
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;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
const from = replyTo.from?.[0];
|
const from = replyTo.from?.[0];
|
||||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
// 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 originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||||
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||||
@@ -281,19 +323,36 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'forward') {
|
if (mode === 'forward') {
|
||||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
return `${prefix}${signatureBlock}\n\n${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') {
|
} 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;
|
return prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
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;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
const from = replyTo.from?.[0];
|
const from = replyTo.from?.[0];
|
||||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
const 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, {
|
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||||
embed: shouldEmbedSignatureAboveQuote,
|
embed: shouldEmbedSignatureAboveQuote,
|
||||||
@@ -302,35 +361,46 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
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 wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||||
const originalHtml = replyTo.htmlBody
|
|
||||||
? rewriteCidImagesForEditor(replyTo.htmlBody)
|
|
||||||
: (replyTo.body
|
|
||||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
|
||||||
: '');
|
|
||||||
const bodyHtml = wrap
|
const bodyHtml = wrap
|
||||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
|
||||||
: originalHtml;
|
: escaped;
|
||||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build quoted content as HTML
|
// Build quoted content as HTML
|
||||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
const quoteHeader = mode === 'forward'
|
const quoteHeader = mode === 'forward'
|
||||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||||
// cid: image refs are rewritten so they render in the editor (browsers
|
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||||
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
|
// its layout survives the editor round-trip. Sanitize first to strip
|
||||||
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
|
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
|
// 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) {
|
if (replyTo.body) {
|
||||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||||
if (mode === 'forward') {
|
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') {
|
} 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;
|
return prefix;
|
||||||
@@ -343,6 +413,7 @@ export function EmailComposer({
|
|||||||
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
|
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
|
||||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||||
|
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
|
||||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||||
// Mirror of draftId for synchronous reads inside chained saves; React's
|
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||||
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||||
@@ -383,6 +454,7 @@ export function EmailComposer({
|
|||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||||
|
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||||
const [smimeSign_, setSmimeSign] = useState(false);
|
const [smimeSign_, setSmimeSign] = useState(false);
|
||||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||||
@@ -390,6 +462,12 @@ export function EmailComposer({
|
|||||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
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({
|
const saveTemplateModalRef = useFocusTrap({
|
||||||
isActive: showSaveAsTemplate,
|
isActive: showSaveAsTemplate,
|
||||||
@@ -449,10 +527,15 @@ export function EmailComposer({
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (!identityChanged && !separatorChanged) return;
|
if (!identityChanged && !separatorChanged) return;
|
||||||
if (plainTextMode) return;
|
if (plainTextMode) return;
|
||||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
// Replies/forwards only embed when configured for "above quote". Compose
|
||||||
if (signaturePosition !== 'above_quote') return;
|
// 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 doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||||
if (!startEl) return;
|
if (!startEl) return;
|
||||||
@@ -474,15 +557,23 @@ export function EmailComposer({
|
|||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|
||||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
// 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 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[] = [];
|
const toRemove: Node[] = [];
|
||||||
let cursor: Node | null = startEl;
|
let cursor: Node | null = startEl;
|
||||||
while (cursor) {
|
while (cursor) {
|
||||||
toRemove.push(cursor);
|
toRemove.push(cursor);
|
||||||
if (cursor === removeUntil) break;
|
if (cursor === removeUntil) break;
|
||||||
const next: Node | null = cursor.nextSibling;
|
const next: Node | null = cursor.nextSibling;
|
||||||
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
|
if (!removeUntil && isQuoteBoundary(next)) break;
|
||||||
cursor = next;
|
cursor = next;
|
||||||
}
|
}
|
||||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||||
@@ -495,6 +586,23 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!autoSelectReplyIdentity) return;
|
if (!autoSelectReplyIdentity) return;
|
||||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
@@ -664,9 +772,16 @@ export function EmailComposer({
|
|||||||
// Ref to latest saveDraft for use in event handlers with stale closures
|
// Ref to latest saveDraft for use in event handlers with stale closures
|
||||||
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
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)
|
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (explicitCloseRef.current) return;
|
||||||
if (onSaveState && isDirtyRef.current) {
|
if (onSaveState && isDirtyRef.current) {
|
||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
onSaveState({
|
onSaveState({
|
||||||
@@ -1011,6 +1126,43 @@ export function EmailComposer({
|
|||||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
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
|
// Auto-save draft functionality
|
||||||
const saveDraftOnce = async (): Promise<string | null> => {
|
const saveDraftOnce = async (): Promise<string | null> => {
|
||||||
if (!client || !composerClient) return null;
|
if (!client || !composerClient) return null;
|
||||||
@@ -1184,6 +1336,33 @@ export function EmailComposer({
|
|||||||
return undefined;
|
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:
|
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||||
// references so recipient clients that strip data URIs can still render them.
|
// references so recipient clients that strip data URIs can still render them.
|
||||||
const rewriteInlineImages = (html: string): {
|
const rewriteInlineImages = (html: string): {
|
||||||
@@ -1227,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 ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -1255,6 +1434,7 @@ export function EmailComposer({
|
|||||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||||
if (matched) {
|
if (matched) {
|
||||||
setAttachmentWarningKeyword(matched);
|
setAttachmentWarningKeyword(matched);
|
||||||
|
setAttachmentWarningDelayedUntil(delayedUntil);
|
||||||
setShowAttachmentWarning(true);
|
setShowAttachmentWarning(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1305,12 +1485,14 @@ export function EmailComposer({
|
|||||||
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||||
|
|
||||||
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||||
// When "above quote" mode is configured for replies/forwards, the signature
|
// The signature is embedded into the body during init for compose mode
|
||||||
// was embedded into the body during init (see getInitialBody) so the
|
// (when the initial identity had a signature) and for above-quote
|
||||||
// trailing append must be skipped to avoid duplicating it.
|
// replies/forwards - skip the trailing append in those cases so we don't
|
||||||
|
// duplicate it.
|
||||||
const signatureAlreadyInBody =
|
const signatureAlreadyInBody =
|
||||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
shouldEmbedSignatureInNewMail ||
|
||||||
signaturePosition === 'above_quote';
|
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
|
signaturePosition === 'above_quote');
|
||||||
|
|
||||||
// Build HTML signature block (used only in rich text mode)
|
// Build HTML signature block (used only in rich text mode)
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
@@ -1343,6 +1525,7 @@ export function EmailComposer({
|
|||||||
const inlineAttachments = rewritten?.attachments ?? [];
|
const inlineAttachments = rewritten?.attachments ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
|
||||||
// Let plugins veto the send (external-mail warning, mistyped-domain
|
// Let plugins veto the send (external-mail warning, mistyped-domain
|
||||||
// guards, etc.). Returning false from any handler aborts before either
|
// guards, etc.). Returning false from any handler aborts before either
|
||||||
// the S/MIME or standard JMAP path runs.
|
// the S/MIME or standard JMAP path runs.
|
||||||
@@ -1492,7 +1675,16 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Send via raw email path
|
// 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 {
|
} else {
|
||||||
// Standard JMAP send path
|
// Standard JMAP send path
|
||||||
// Collect uploaded attachment blobIds for the send request
|
// Collect uploaded attachment blobIds for the send request
|
||||||
@@ -1542,6 +1734,8 @@ export function EmailComposer({
|
|||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
references: threadingHeaders?.references,
|
||||||
|
requestReadReceipt,
|
||||||
|
delayedUntil: effectiveDelayedUntil,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mode === 'reply' || mode === 'replyAll') {
|
if (mode === 'reply' || mode === 'replyAll') {
|
||||||
@@ -1566,15 +1760,56 @@ export function EmailComposer({
|
|||||||
setDraftId(null);
|
setDraftId(null);
|
||||||
setSubAddressTag("");
|
setSubAddressTag("");
|
||||||
setValidationErrors({});
|
setValidationErrors({});
|
||||||
|
setShowScheduleDialog(false);
|
||||||
|
setScheduleValue('');
|
||||||
|
setScheduleError('');
|
||||||
// Clear ref so unmount effect doesn't re-save
|
// Clear ref so unmount effect doesn't re-save
|
||||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error('Failed to send email:', err);
|
debug.error('Failed to send email:', err);
|
||||||
toast.error(t('send_failed'));
|
toast.error(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 = () => {
|
const cleanClose = () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -1583,6 +1818,7 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraftAndClose = async () => {
|
const handleSaveDraftAndClose = async () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
setShowCloseDialog(false);
|
setShowCloseDialog(false);
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -1593,6 +1829,7 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardAndClose = () => {
|
const handleDiscardAndClose = () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
setShowCloseDialog(false);
|
setShowCloseDialog(false);
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -1612,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 (
|
return (
|
||||||
<div className={cn("flex h-full bg-background", className)}>
|
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
name="composer-sidebar"
|
name="composer-sidebar"
|
||||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||||
@@ -1626,6 +1905,7 @@ export function EmailComposer({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onKeyDown={handleComposerKeyDown}
|
||||||
>
|
>
|
||||||
{/* Drag overlay */}
|
{/* Drag overlay */}
|
||||||
{isDraggingOver && (
|
{isDraggingOver && (
|
||||||
@@ -1946,8 +2226,9 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hide the visual signature preview when the signature has already been
|
{/* Hide the visual signature preview when the signature has already been
|
||||||
embedded into the body above the quote (otherwise it would appear twice). */}
|
embedded into the body (compose, or above-quote replies). */}
|
||||||
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
|
{(shouldEmbedSignatureInNewMail
|
||||||
|
|| ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote')) ? null
|
||||||
: plainTextMode ? (
|
: plainTextMode ? (
|
||||||
getPlainTextSignature(signatureIdentity) ? (
|
getPlainTextSignature(signatureIdentity) ? (
|
||||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||||
@@ -1966,7 +2247,21 @@ export function EmailComposer({
|
|||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="px-4 py-2 border-t shrink-0">
|
<div className="px-4 py-2 border-t shrink-0">
|
||||||
<div className="flex flex-wrap gap-2">
|
<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
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1988,10 +2283,18 @@ export function EmailComposer({
|
|||||||
) : (
|
) : (
|
||||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
{canPreview ? (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<button
|
||||||
({formatFileSize(att.size)})
|
type="button"
|
||||||
</span>
|
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
|
<button
|
||||||
onClick={() => removeAttachment(index)}
|
onClick={() => removeAttachment(index)}
|
||||||
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||||
@@ -2001,7 +2304,8 @@ export function EmailComposer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{attachments.length > 3 && (
|
{attachments.length > 3 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAllAttachments(prev => !prev)}
|
onClick={() => setShowAllAttachments(prev => !prev)}
|
||||||
@@ -2015,7 +2319,7 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom toolbar */}
|
{/* Bottom toolbar */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 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 */}
|
{/* Left side actions */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -2053,7 +2357,6 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
<BookmarkPlus className="w-4 h-4" />
|
<BookmarkPlus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* S/MIME toggles */}
|
{/* S/MIME toggles */}
|
||||||
{canSmimeSign && (
|
{canSmimeSign && (
|
||||||
<>
|
<>
|
||||||
@@ -2079,6 +2382,21 @@ export function EmailComposer({
|
|||||||
</Button>
|
</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" />
|
<PluginSlot name="composer-toolbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2091,15 +2409,56 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
{t('discard')}
|
{t('discard')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
{composerClient?.hasDelayedSend() ? (
|
||||||
onClick={() => handleSend()}
|
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
|
||||||
disabled={!canSend}
|
<Button
|
||||||
title={getSendTooltip()}
|
onClick={() => handleSend()}
|
||||||
className="hidden md:inline-flex"
|
disabled={!canSend}
|
||||||
>
|
title={getSendTooltip()}
|
||||||
<Send className="w-4 h-4 mr-2" />
|
className="rounded-r-none border-r border-primary-foreground/20"
|
||||||
{t('send')}
|
>
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2138,6 +2497,29 @@ export function EmailComposer({
|
|||||||
</div>
|
</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 */}
|
{/* S/MIME passphrase prompt */}
|
||||||
{smimePassphrasePrompt && (
|
{smimePassphrasePrompt && (
|
||||||
<div
|
<div
|
||||||
@@ -2214,7 +2596,7 @@ export function EmailComposer({
|
|||||||
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||||
{t('forgot_attachment.back')}
|
{t('forgot_attachment.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
|
||||||
{t('forgot_attachment.send_anyway')}
|
{t('forgot_attachment.send_anyway')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2253,6 +2635,15 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{previewAttachment && (
|
||||||
|
<FilePreviewModal
|
||||||
|
name={previewAttachment.name}
|
||||||
|
onClose={() => setPreviewAttachment(null)}
|
||||||
|
onDownload={handlePreviewAttachmentDownload}
|
||||||
|
getFileContent={getPreviewAttachmentContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
name="composer-sidebar-right"
|
name="composer-sidebar-right"
|
||||||
@@ -2461,6 +2852,11 @@ function RecipientChipInput({
|
|||||||
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
|
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
|
||||||
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||||
aria-invalid={validationError || undefined}
|
aria-invalid={validationError || undefined}
|
||||||
|
data-bwignore="true"
|
||||||
|
data-1p-ignore
|
||||||
|
data-op-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-form-type="other"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{validationError && validationMessage && (
|
{validationError && validationMessage && (
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
CalendarClock,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
|
|||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCancelScheduled?: () => void;
|
||||||
|
onCancelScheduledForEdit?: () => void;
|
||||||
|
onRescheduleScheduled?: () => void;
|
||||||
// Batch actions
|
// Batch actions
|
||||||
onBatchMarkAsRead?: (read: boolean) => void;
|
onBatchMarkAsRead?: (read: boolean) => void;
|
||||||
onBatchDelete?: () => void;
|
onBatchDelete?: () => void;
|
||||||
@@ -133,6 +138,9 @@ export function EmailContextMenu({
|
|||||||
onBatchMarkAsSpam,
|
onBatchMarkAsSpam,
|
||||||
onBatchUndoSpam,
|
onBatchUndoSpam,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
@@ -143,6 +151,8 @@ export function EmailContextMenu({
|
|||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
const isScheduled = email.isScheduled === true;
|
||||||
|
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
// Build color options from keyword definitions in settings
|
// Build color options from keyword definitions in settings
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
@@ -196,8 +206,36 @@ export function EmailContextMenu({
|
|||||||
</ContextMenuHeader>
|
</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 */}
|
{/* Edit Draft - only for single draft emails */}
|
||||||
{!showBatchActions && isDraft && onEditDraft && (
|
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={EditIcon}
|
icon={EditIcon}
|
||||||
@@ -209,7 +247,7 @@ export function EmailContextMenu({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Single email actions - Reply, Reply All, Forward */}
|
{/* Single email actions - Reply, Reply All, Forward */}
|
||||||
{!showBatchActions && (
|
{!isScheduled && !showBatchActions && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Reply}
|
icon={Reply}
|
||||||
@@ -375,6 +413,8 @@ export function EmailContextMenu({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<PluginSlot name="context-menu-email" />
|
<PluginSlot name="context-menu-email" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Email, Identity } from '@/lib/jmap/types';
|
import type { Email, Identity } from '@/lib/jmap/types';
|
||||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||||
|
import { isAuthenticationSpoofed } from '@/lib/email-headers';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
interface EmailIdentityBadgeProps {
|
interface EmailIdentityBadgeProps {
|
||||||
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
|
|||||||
// Parse the from address to check for sub-addressing
|
// Parse the from address to check for sub-addressing
|
||||||
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||||
|
|
||||||
// Find matching identity (email sent BY the user)
|
// Find matching identity (email sent BY the user). When the message is
|
||||||
const matchingIdentity = identities.find(
|
// likely spoofed, the From address can't be trusted, so we ignore any
|
||||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
// 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)
|
// Check if email was sent TO a sub-address (received email)
|
||||||
let receivedToTag: string | null = null;
|
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 currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const 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 hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||||
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
sourceMailboxId: selectedMailbox,
|
sourceMailboxId: selectedMailbox,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
|
||||||
|
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||||
useCallback((pos) => {
|
useCallback((pos) => {
|
||||||
onContextMenu?.(
|
onContextMenu?.(
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { ThreadListItem } from "./thread-list-item";
|
import { ThreadListItem } from "./thread-list-item";
|
||||||
import { EmailContextMenu } from "./email-context-menu";
|
import { EmailContextMenu } from "./email-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
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 { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
@@ -26,6 +27,8 @@ interface EmailListProps {
|
|||||||
onEmailDoubleClick?: (email: Email) => void;
|
onEmailDoubleClick?: (email: Email) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
isLoadingMoreItems?: boolean;
|
||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
@@ -39,6 +42,11 @@ interface EmailListProps {
|
|||||||
onMarkAsSpam?: (email: Email) => void;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
onEditDraft?: (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({
|
export function EmailList({
|
||||||
@@ -48,6 +56,8 @@ export function EmailList({
|
|||||||
onEmailDoubleClick,
|
onEmailDoubleClick,
|
||||||
className,
|
className,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
hasMore,
|
||||||
|
isLoadingMoreItems,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
@@ -61,6 +71,11 @@ export function EmailList({
|
|||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
isScheduledView = false,
|
||||||
|
onLoadMoreScheduled,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
}: EmailListProps) {
|
}: EmailListProps) {
|
||||||
const t = useTranslations('email_list');
|
const t = useTranslations('email_list');
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
@@ -95,9 +110,9 @@ export function EmailList({
|
|||||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails, disableThreading);
|
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails, disableThreading]);
|
}, [emails, disableThreading, isScheduledView]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -107,7 +122,11 @@ export function EmailList({
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
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(() => {
|
const estimateSize = useCallback(() => {
|
||||||
if (isFocusedMailLayout) {
|
if (isFocusedMailLayout) {
|
||||||
@@ -216,10 +235,14 @@ export function EmailList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (isScheduledView) {
|
||||||
|
onLoadMoreScheduled?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||||
loadMoreEmails(client);
|
loadMoreEmails(client);
|
||||||
}
|
}
|
||||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||||
|
|
||||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||||
const isExpanded = expandedThreadIds.has(threadId);
|
const isExpanded = expandedThreadIds.has(threadId);
|
||||||
@@ -279,7 +302,7 @@ export function EmailList({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
"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">
|
<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 ? (
|
) : emails.length === 0 && !isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
<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">
|
<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" />
|
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<MailX className="w-10 h-10 text-muted-foreground" />
|
<MailX className="w-10 h-10 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-medium text-foreground">
|
<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>
|
||||||
<p className="text-sm mt-1 text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -465,13 +490,13 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-4 flex justify-center">
|
<div className="py-4 flex justify-center">
|
||||||
{isLoadingMore && hasMoreEmails && (
|
{footerIsLoadingMore && footerHasMore && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<span>{t('loading_more')}</span>
|
<span>{t('loading_more')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMoreEmails && emails.length > 0 && (
|
{!footerHasMore && emails.length > 0 && (
|
||||||
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
||||||
{t('no_more_emails')}
|
{t('no_more_emails')}
|
||||||
</div>
|
</div>
|
||||||
@@ -506,6 +531,9 @@ export function EmailList({
|
|||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||||
onEditDraft={() => onEditDraft?.(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)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchArchive={async () => {
|
onBatchArchive={async () => {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
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 { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
@@ -63,6 +66,7 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
|
CalendarClock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
@@ -73,11 +77,14 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
|
|||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
import { CalendarInvitationBanner } from "./calendar-invitation-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 { useTour } from "@/components/tour/tour-provider";
|
||||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
@@ -122,6 +129,9 @@ interface EmailViewerProps {
|
|||||||
onNavigatePrev?: () => void;
|
onNavigatePrev?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCancelScheduled?: () => void;
|
||||||
|
onCancelScheduledForEdit?: () => void;
|
||||||
|
onRescheduleScheduled?: (delayedUntil: string) => void;
|
||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
@@ -799,6 +809,7 @@ interface DraggableAttachmentChipProps {
|
|||||||
attachment: EffectiveAttachment;
|
attachment: EffectiveAttachment;
|
||||||
client: IJMAPClient | null;
|
client: IJMAPClient | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
downloadName?: string;
|
||||||
children: (dragProps: {
|
children: (dragProps: {
|
||||||
draggable: boolean;
|
draggable: boolean;
|
||||||
onPointerEnter: () => void;
|
onPointerEnter: () => void;
|
||||||
@@ -807,9 +818,9 @@ interface DraggableAttachmentChipProps {
|
|||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
|
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||||
const source = useMemo<AttachmentDragSource>(() => ({
|
const source = useMemo<AttachmentDragSource>(() => ({
|
||||||
name: attachment.name || 'download',
|
name: downloadName || attachment.name || 'download',
|
||||||
type: attachment.type || 'application/octet-stream',
|
type: attachment.type || 'application/octet-stream',
|
||||||
getBlobUrl: async () => {
|
getBlobUrl: async () => {
|
||||||
if (attachment.blobId && client) {
|
if (attachment.blobId && client) {
|
||||||
@@ -832,7 +843,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
}), [attachment, client]);
|
}), [attachment, client, downloadName]);
|
||||||
const drag = useAttachmentDrag(source, enabled);
|
const drag = useAttachmentDrag(source, enabled);
|
||||||
return <>{children(drag)}</>;
|
return <>{children(drag)}</>;
|
||||||
}
|
}
|
||||||
@@ -870,6 +881,9 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
onCompose,
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
@@ -879,6 +893,7 @@ export function EmailViewer({
|
|||||||
className,
|
className,
|
||||||
}: EmailViewerProps) {
|
}: EmailViewerProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
|
const tComposer = useTranslations('email_composer');
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tSmime = useTranslations('smime');
|
const tSmime = useTranslations('smime');
|
||||||
@@ -898,9 +913,30 @@ export function EmailViewer({
|
|||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
|
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
|
||||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
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 timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -909,6 +945,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Detect if the email is a draft
|
// Detect if the email is a draft
|
||||||
const isDraft = email?.keywords?.['$draft'] === true;
|
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)
|
// Color options for email tags (from user-defined keyword settings)
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
@@ -921,6 +959,30 @@ export function EmailViewer({
|
|||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
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 resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
@@ -2134,6 +2196,9 @@ export function EmailViewer({
|
|||||||
// Hide inline cid-referenced images when the user has opted to keep them
|
// 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.
|
// 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/')))
|
.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) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
name: attachment.name || null,
|
name: attachment.name || null,
|
||||||
@@ -2561,6 +2626,15 @@ export function EmailViewer({
|
|||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [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 handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
|
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
|
||||||
@@ -2570,6 +2644,8 @@ export function EmailViewer({
|
|||||||
&& mailAttachmentAction === 'preview'
|
&& mailAttachmentAction === 'preview'
|
||||||
&& isMimeTypeSafeForInlinePreview(attachment.type);
|
&& isMimeTypeSafeForInlinePreview(attachment.type);
|
||||||
|
|
||||||
|
const downloadName = resolveAttachmentName(attachment);
|
||||||
|
|
||||||
const info: AttachmentInfo = {
|
const info: AttachmentInfo = {
|
||||||
name: attachment.name || '',
|
name: attachment.name || '',
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
@@ -2580,7 +2656,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (attachment.blobId && onDownloadAttachment) {
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
emailHooks.onAttachmentDownload.emit(info);
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2600,7 +2676,7 @@ export function EmailViewer({
|
|||||||
emailHooks.onAttachmentDownload.emit(info);
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = downloadName;
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
@@ -2630,16 +2706,17 @@ export function EmailViewer({
|
|||||||
emailHooks.onAttachmentDownload.emit(info);
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = downloadName;
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
|
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
|
||||||
|
|
||||||
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
||||||
|
const downloadName = resolveAttachmentName(attachment);
|
||||||
const info: AttachmentInfo = {
|
const info: AttachmentInfo = {
|
||||||
name: attachment.name || '',
|
name: attachment.name || '',
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
@@ -2649,7 +2726,7 @@ export function EmailViewer({
|
|||||||
};
|
};
|
||||||
emailHooks.onAttachmentDownload.emit(info);
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
if (attachment.blobId && onDownloadAttachment) {
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2662,7 +2739,7 @@ export function EmailViewer({
|
|||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = downloadName;
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
@@ -2678,12 +2755,12 @@ export function EmailViewer({
|
|||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = downloadName;
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
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
|
// 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.
|
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||||
@@ -2799,7 +2876,9 @@ export function EmailViewer({
|
|||||||
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
// 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.
|
// @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 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
|
// 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;
|
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||||
@@ -2820,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">
|
<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}">
|
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||||
<style>
|
<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; }
|
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; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
a { color: #1a73e8; }
|
a { color: #1a73e8; }
|
||||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
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; }
|
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||||
${wordHtmlCSS}
|
${wordHtmlCSS}
|
||||||
${darkModeCSS}
|
${darkModeCSS}
|
||||||
@@ -3056,35 +3138,58 @@ export function EmailViewer({
|
|||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
if (!email?.blobId || !client) return;
|
if (!email?.blobId || !client) return;
|
||||||
try {
|
try {
|
||||||
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100);
|
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||||
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(tNotifications('export_email_error'));
|
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 = () => {
|
const handleImportEmail = () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.eml,message/rfc822';
|
input.accept = EML_IMPORT_ACCEPT;
|
||||||
|
input.multiple = true;
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||||
if (!file) return;
|
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 {
|
try {
|
||||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
emails = await expandImportableEmails(files);
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
} catch {
|
||||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
toast.error(tNotifications('import_email_error'));
|
||||||
if (!mailboxId) {
|
return;
|
||||||
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'));
|
toast.success(tNotifications('import_email_success'));
|
||||||
await fetchEmails(client);
|
await fetchEmails(client);
|
||||||
} catch {
|
}
|
||||||
|
if (failed > 0 || emails.length === 0) {
|
||||||
toast.error(tNotifications('import_email_error'));
|
toast.error(tNotifications('import_email_error'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3156,6 +3261,103 @@ export function EmailViewer({
|
|||||||
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
|
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
|
||||||
: false;
|
: 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
|
// Show loading skeleton while email is being fetched
|
||||||
if (isLoading && !email) {
|
if (isLoading && !email) {
|
||||||
return (
|
return (
|
||||||
@@ -3208,9 +3410,9 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
if (isDemoMode) {
|
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_White_and_Color.svg'
|
||||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||||
return (
|
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={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">
|
<div className="text-center p-8 max-w-md">
|
||||||
@@ -3295,7 +3497,32 @@ export function EmailViewer({
|
|||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3307,7 +3534,7 @@ export function EmailViewer({
|
|||||||
<span className="text-sm">{t('edit_draft')}</span>
|
<span className="text-sm">{t('edit_draft')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isDraft && (<>
|
{!isScheduled && !isDraft && (<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3349,6 +3576,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
{/* 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">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<Button
|
<Button
|
||||||
@@ -3806,6 +4034,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3816,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)}
|
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 */}
|
{/* Mobile More menu sidebar overlay */}
|
||||||
{isMobile && moreMenuOpen && (
|
{!isScheduled && isMobile && moreMenuOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
||||||
onClick={() => setMoreMenuOpen(false)}
|
onClick={() => setMoreMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isMobile && (
|
{!isScheduled && isMobile && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
|
"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",
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
@@ -3997,8 +4226,8 @@ export function EmailViewer({
|
|||||||
"bg-background border-b border-border",
|
"bg-background border-b border-border",
|
||||||
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
"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 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">
|
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2 w-full">
|
||||||
{renderToolbarItems(true)}
|
{renderToolbarItems(true)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4209,7 +4438,7 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -4289,7 +4518,7 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
@@ -4588,9 +4817,27 @@ export function EmailViewer({
|
|||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{auth?.spf && (
|
{auth?.spf && (() => {
|
||||||
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_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 && (
|
{auth?.dkim && (
|
||||||
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
||||||
)}
|
)}
|
||||||
@@ -4754,10 +5001,44 @@ export function EmailViewer({
|
|||||||
</div>
|
</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 */}
|
{/* Draft Banner */}
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<div className="border-b border-border bg-warning/10">
|
<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">
|
<div className="flex items-center gap-2 text-warning">
|
||||||
<File className="w-4 h-4" />
|
<File className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
||||||
@@ -4789,9 +5070,10 @@ export function EmailViewer({
|
|||||||
error={smimeUnlockError}
|
error={smimeUnlockError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Unified Notification Banner - External Content + Calendar Invitation */}
|
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||||
hasCalendarInvitation) && (
|
hasCalendarInvitation ||
|
||||||
|
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||||
<div className="border-b border-border bg-muted/30 isolate">
|
<div className="border-b border-border bg-muted/30 isolate">
|
||||||
<div className="px-6 py-1.5">
|
<div className="px-6 py-1.5">
|
||||||
<div className="flex flex-col gap-3 isolate">
|
<div className="flex flex-col gap-3 isolate">
|
||||||
@@ -4846,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 */}
|
{/* Calendar Invitation Banner */}
|
||||||
{hasCalendarInvitation && (
|
{hasCalendarInvitation && (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
@@ -4908,7 +5201,7 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -4993,7 +5286,7 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
@@ -5051,7 +5344,7 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -5130,7 +5423,7 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
@@ -5226,14 +5519,16 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
{/* 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 items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={currentUserName || "You"}
|
name={activeAccount?.displayName || currentUserName || "You"}
|
||||||
email={currentUserEmail || ""}
|
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="shadow-sm w-10 h-10"
|
className="shadow-sm w-10 h-10"
|
||||||
|
disableFavicon
|
||||||
|
fallbackColor={activeAccount?.avatarColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-3">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { Table } from "@tiptap/extension-table";
|
|||||||
import { TableRow } from "@tiptap/extension-table-row";
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
import { TableHeader } from "@tiptap/extension-table-header";
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
import { TableCell } from "@tiptap/extension-table-cell";
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
|
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
@@ -231,6 +232,9 @@ export function RichTextEditor({
|
|||||||
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
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,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -281,14 +285,18 @@ export function RichTextEditor({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor }) => {
|
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,
|
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(() => {
|
useEffect(() => {
|
||||||
if (editor && content !== editor.getHTML()) {
|
if (editor && content !== serializeEditorContent(editor)) {
|
||||||
editor.commands.setContent(content, { emitUpdate: false });
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
}
|
}
|
||||||
}, [content, editor]);
|
}, [content, editor]);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback } from "react";
|
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 { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
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 { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -67,15 +67,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
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 trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
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
|
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||||
const tagIds = getEmailColorTags(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
@@ -91,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
sourceMailboxId: selectedMailbox,
|
sourceMailboxId: selectedMailbox,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
|
||||||
|
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||||
useCallback((pos) => {
|
useCallback((pos) => {
|
||||||
onContextMenu?.(
|
onContextMenu?.(
|
||||||
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{resolvedKeywordDefs.map((kd) => (
|
{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 key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||||
))}
|
))}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
'text-xs tabular-nums',
|
<span
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
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}
|
||||||
{formatDate(email.receivedAt)}
|
>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{kd.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
"text-xs tabular-nums",
|
<span
|
||||||
isUnread
|
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"
|
||||||
? "text-foreground font-semibold"
|
title={scheduledSendLabel}
|
||||||
: "text-muted-foreground"
|
>
|
||||||
)}>
|
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||||
{formatDate(email.receivedAt)}
|
<span className="truncate">{scheduledSendLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
{!email.isScheduled && (
|
||||||
email={email}
|
<EmailHoverActions
|
||||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
email={email}
|
||||||
onToggleStar={onToggleStar}
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onToggleStar={onToggleStar}
|
||||||
onDelete={onDelete}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onArchive={onArchive}
|
onDelete={onDelete}
|
||||||
onSetColorTag={onSetColorTag}
|
onArchive={onArchive}
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
onSetColorTag={onSetColorTag}
|
||||||
/>
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -374,12 +400,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
// 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 trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
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 { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
@@ -645,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{keywordDef && (
|
{keywordDef && (
|
||||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
'text-xs tabular-nums',
|
<span
|
||||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
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}
|
||||||
{formatDate(latestEmail.receivedAt)}
|
>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -715,14 +756,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{keywordDef.label}
|
{keywordDef.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
"text-xs tabular-nums",
|
<span
|
||||||
hasUnread
|
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"
|
||||||
? "text-foreground font-semibold"
|
title={scheduledSendLabel}
|
||||||
: "text-muted-foreground"
|
>
|
||||||
)}>
|
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||||
{formatDate(latestEmail.receivedAt)}
|
<span className="truncate">{scheduledSendLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
hasUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -751,16 +802,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
{!latestEmail.isScheduled && (
|
||||||
email={latestEmail}
|
<EmailHoverActions
|
||||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
email={latestEmail}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||||
/>
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
{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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { 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 {
|
interface FilePreviewModalProps {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -111,6 +149,54 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
|
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;
|
const fileType = resolvedFileType;
|
||||||
|
|
||||||
@@ -120,6 +206,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
|
|
||||||
setContent(null);
|
setContent(null);
|
||||||
setObjectUrl(null);
|
setObjectUrl(null);
|
||||||
|
setCanOpenInNewTab(false);
|
||||||
|
setEmlContent(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
setResolvedFileType(getFilePreviewKind(name));
|
setResolvedFileType(getFilePreviewKind(name));
|
||||||
@@ -136,9 +224,36 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
if (previewType === "text" || previewType === "markdown") {
|
if (previewType === "text" || previewType === "markdown") {
|
||||||
const text = await blob.text();
|
const text = await blob.text();
|
||||||
if (!cancelled) setContent(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 {
|
} else {
|
||||||
revokeUrl = URL.createObjectURL(blob);
|
// Stalwart's download endpoint can return generic
|
||||||
if (!cancelled) setObjectUrl(revokeUrl);
|
// 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 {
|
} catch {
|
||||||
if (!cancelled) setError(true);
|
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()}>
|
<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>
|
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||||
<div className="flex items-center gap-2">
|
<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()}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -211,6 +338,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && fileType === "eml" && emlContent && (
|
||||||
|
<EmlPreview message={emlContent} />
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && !error && fileType === "image" && objectUrl && (
|
{!loading && !error && fileType === "image" && objectUrl && (
|
||||||
<img
|
<img
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
@@ -231,19 +362,29 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && (
|
||||||
<object
|
// <iframe> renders PDFs reliably across desktop Chromium, Firefox,
|
||||||
data={objectUrl}
|
// and Safari from a blob: URL. <object> was prone to falling back to
|
||||||
type="application/pdf"
|
// 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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Button onClick={() => void onDownload()}>
|
<PdfMobileViewer url={objectUrl} />
|
||||||
<Download className="w-4 h-4 mr-2" />
|
</div>
|
||||||
{t("download")}
|
|
||||||
</Button>
|
|
||||||
</object>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && fileType === "audio" && objectUrl && (
|
{!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[] = [
|
const ALL_FIELDS: FilterConditionField[] = [
|
||||||
"from", "to", "cc", "subject", "header", "size", "body",
|
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||||
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
|
|||||||
|
|
||||||
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
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[] = [
|
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
"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: "" };
|
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 {
|
function makeEmptyAction(): FilterAction {
|
||||||
return { type: "move", value: "" };
|
return { type: "move", value: "" };
|
||||||
}
|
}
|
||||||
@@ -97,9 +126,23 @@ export function FilterRuleModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validConditions = conditions.filter(
|
// While editing, condition.value is always the raw string typed into the
|
||||||
(c) => c.value.trim()
|
// 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) {
|
if (validConditions.length === 0) {
|
||||||
toast.error(t("validation_empty_conditions"));
|
toast.error(t("validation_empty_conditions"));
|
||||||
return;
|
return;
|
||||||
@@ -129,15 +172,27 @@ export function FilterRuleModal({
|
|||||||
prev.map((c, i) => {
|
prev.map((c, i) => {
|
||||||
if (i !== index) return c;
|
if (i !== index) return c;
|
||||||
const updated = { ...c, ...updates };
|
const updated = { ...c, ...updates };
|
||||||
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
|
// Reconcile the comparator when the field changes so we never end up
|
||||||
updated.comparator = "greater_than";
|
// 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 !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
|
if (updates.field && updates.field !== c.field) {
|
||||||
updated.comparator = "contains";
|
const allowed = comparatorsFor(updates.field);
|
||||||
|
if (!allowed.includes(c.comparator)) {
|
||||||
|
updated.comparator = allowed[0];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (updates.field && updates.field !== "header") {
|
if (updates.field && updates.field !== "header") {
|
||||||
delete updated.headerName;
|
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;
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -281,24 +336,58 @@ export function FilterRuleModal({
|
|||||||
className={selectClass}
|
className={selectClass}
|
||||||
aria-label={t("comparators.contains")}
|
aria-label={t("comparators.contains")}
|
||||||
>
|
>
|
||||||
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
|
{comparatorsFor(condition.field).map((c) => (
|
||||||
(c) => (
|
<option key={c} value={c}>
|
||||||
<option key={c} value={c}>
|
{t(`comparators.${c}`)}
|
||||||
{t(`comparators.${c}`)}
|
</option>
|
||||||
</option>
|
))}
|
||||||
)
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<Input
|
{/* has_any takes no value; render a stub so the row layout
|
||||||
value={condition.value}
|
stays consistent but no input is editable. */}
|
||||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
{condition.field === "attachment" && condition.comparator === "has_any" ? (
|
||||||
placeholder={
|
<div className="flex-1 min-w-[120px]" />
|
||||||
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
|
) : (
|
||||||
}
|
<Input
|
||||||
className="flex-1 min-w-[120px]"
|
value={valueToInputString(condition.value)}
|
||||||
type={condition.field === "size" ? "number" : "text"}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
|||||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
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 SIGNATURE_MAX_BYTES = 2047;
|
||||||
const utf8Encoder = new TextEncoder();
|
const utf8Encoder = new TextEncoder();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
|
|||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { apiFetch } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -385,7 +385,7 @@ export function NavigationRail({
|
|||||||
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href={`${getPathPrefix()}/admin`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
"transition-colors duration-150",
|
"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 ? (
|
return logoUrl ? (
|
||||||
<div className="flex items-center justify-center py-3 px-1">
|
<div className="flex items-center justify-center py-3 px-1">
|
||||||
<img
|
<img
|
||||||
@@ -572,7 +572,7 @@ export function NavigationRail({
|
|||||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<a
|
<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"
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||||
title={t("admin") || "Admin"}
|
title={t("admin") || "Admin"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, ReactNode } from "react";
|
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
@@ -74,6 +74,8 @@ interface SidebarProps {
|
|||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
onImportEmail?: (mailboxId: string) => void;
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
onRefreshMailboxes?: () => void;
|
onRefreshMailboxes?: () => void;
|
||||||
|
scheduledTotal?: number;
|
||||||
|
showScheduledMailbox?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||||
@@ -674,6 +676,8 @@ export function Sidebar({
|
|||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onImportEmail,
|
onImportEmail,
|
||||||
onRefreshMailboxes,
|
onRefreshMailboxes,
|
||||||
|
scheduledTotal = 0,
|
||||||
|
showScheduledMailbox = false,
|
||||||
className,
|
className,
|
||||||
multiAccountMode = false,
|
multiAccountMode = false,
|
||||||
accountMailboxes,
|
accountMailboxes,
|
||||||
@@ -732,15 +736,20 @@ export function Sidebar({
|
|||||||
// pane.
|
// pane.
|
||||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
||||||
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
||||||
|
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
|
||||||
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
||||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||||
const accounts = useAccountStore(s => s.accounts);
|
const accounts = useAccountStore(s => s.accounts);
|
||||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
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
|
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
|
||||||
// 2+ account requirement still applies - with a single account the
|
// single account we still surface unified when the user has opted into
|
||||||
// unified counts would just duplicate that account's inbox.
|
// merging group/shared inboxes — otherwise the counts would just duplicate
|
||||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
// the one inbox.
|
||||||
|
const showUnified =
|
||||||
|
(multiAccountMode || enableUnifiedMailbox) &&
|
||||||
|
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
|
||||||
const { unifiedCounts } = useEmailStore();
|
const { unifiedCounts } = useEmailStore();
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
@@ -895,11 +904,11 @@ export function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openFolderSettings = () => {
|
const openFolderSettings = () => {
|
||||||
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ }
|
||||||
router.push('/settings');
|
router.push('/settings');
|
||||||
};
|
};
|
||||||
const openKeywordSettings = () => {
|
const openKeywordSettings = () => {
|
||||||
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ }
|
||||||
router.push('/settings');
|
router.push('/settings');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -933,30 +942,34 @@ export function Sidebar({
|
|||||||
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
||||||
would otherwise render an empty strip (no collapse, no switcher). */}
|
would otherwise render an empty strip (no collapse, no switcher). */}
|
||||||
{!isEmbedded && (
|
{!isEmbedded && (
|
||||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
// Border lives on the wrapper (outside the h-14 box) so the bar's total
|
||||||
<Button
|
// height matches the search/reply toolbars, which border-b their wrapper too.
|
||||||
variant="ghost"
|
<div className="border-b border-border">
|
||||||
size="icon"
|
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
|
||||||
onClick={onSidebarClose}
|
<Button
|
||||||
className="lg:hidden h-9 w-9 flex-shrink-0"
|
variant="ghost"
|
||||||
aria-label={t("close")}
|
size="icon"
|
||||||
>
|
onClick={onSidebarClose}
|
||||||
<X className="w-5 h-5" />
|
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||||
</Button>
|
aria-label={t("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebarCollapsed}
|
onClick={toggleSidebarCollapsed}
|
||||||
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||||
>
|
>
|
||||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && !hideAccountSwitcher && (
|
{!isCollapsed && !hideAccountSwitcher && (
|
||||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1022,22 +1035,35 @@ export function Sidebar({
|
|||||||
{!isCollapsed && t("loading_mailboxes")}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tree.map((node) => (
|
<>
|
||||||
<MailboxTreeItem
|
{tree.map((node) => (
|
||||||
key={node.id}
|
<MailboxTreeItem
|
||||||
node={node}
|
key={node.id}
|
||||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
node={node}
|
||||||
expandedFolders={expandedFolders}
|
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||||
onMailboxSelect={(mailboxId) =>
|
expandedFolders={expandedFolders}
|
||||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
onMailboxSelect={(mailboxId) =>
|
||||||
}
|
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||||
onToggleExpand={handleToggleExpand}
|
}
|
||||||
isCollapsed={isCollapsed}
|
onToggleExpand={handleToggleExpand}
|
||||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
isCollapsed={isCollapsed}
|
||||||
colorful={colorfulSidebarIcons}
|
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||||
onContextMenu={isActive ? handleMailboxContextMenu : 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")}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
ownTree.map((node) => (
|
<>
|
||||||
<MailboxTreeItem
|
{ownTree.map((node) => (
|
||||||
key={node.id}
|
<MailboxTreeItem
|
||||||
node={node}
|
key={node.id}
|
||||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
node={node}
|
||||||
expandedFolders={expandedFolders}
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
onMailboxSelect={onMailboxSelect}
|
expandedFolders={expandedFolders}
|
||||||
onToggleExpand={handleToggleExpand}
|
onMailboxSelect={onMailboxSelect}
|
||||||
isCollapsed={isCollapsed}
|
onToggleExpand={handleToggleExpand}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
isCollapsed={isCollapsed}
|
||||||
colorful={colorfulSidebarIcons}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
onContextMenu={handleMailboxContextMenu}
|
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 React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
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 {
|
export function PluginDialogHost(): React.JSX.Element | null {
|
||||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||||
|
|
||||||
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background, #fff)',
|
background: 'var(--color-popover, #fff)',
|
||||||
color: 'var(--foreground, #0f172a)',
|
color: 'var(--color-popover-foreground, #0f172a)',
|
||||||
border: '1px solid var(--border, #e2e8f0)',
|
border: '1px solid var(--color-border, #e2e8f0)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
maxWidth: 480,
|
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' }}>
|
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||||
{current.title}
|
{current.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||||
{current.message}
|
{renderMessage(current.message)}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
{current.kind === 'confirm' && (
|
{current.kind === 'confirm' && (
|
||||||
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: '1px solid var(--border, #e2e8f0)',
|
border: '1px solid var(--color-border, #e2e8f0)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
}}
|
}}
|
||||||
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: '1px solid transparent',
|
border: '1px solid transparent',
|
||||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||||
color: '#fff',
|
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
From plugin: {current.pluginId}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import type { SlotName } from '@/lib/plugin-types';
|
import type { SlotName } from '@/lib/plugin-types';
|
||||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||||
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
|
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 {
|
interface Props {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -69,6 +71,16 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
|||||||
instanceRef.current?.updateProps(extraProps ?? {});
|
instanceRef.current?.updateProps(extraProps ?? {});
|
||||||
}, [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;
|
if (show !== true) return null;
|
||||||
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
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 client = useAuthStore((s) => s.client);
|
||||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
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 selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||||
|
const isScheduledView = useEmailStore((s) => s.isScheduledView);
|
||||||
const closeTab = useProTabStore((s) => s.closeTab);
|
const closeTab = useProTabStore((s) => s.closeTab);
|
||||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||||
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
const tabIdRef = useRef(tabId);
|
const tabIdRef = useRef(tabId);
|
||||||
tabIdRef.current = 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]) => {
|
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
const result = await sendEmail(
|
||||||
client,
|
client,
|
||||||
sendData.to,
|
sendData.to,
|
||||||
sendData.subject,
|
sendData.subject,
|
||||||
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
sendData.attachments,
|
sendData.attachments,
|
||||||
sendData.inReplyTo,
|
sendData.inReplyTo,
|
||||||
sendData.references,
|
sendData.references,
|
||||||
|
sendData.delayedUntil,
|
||||||
sendData.envelopeMailFrom,
|
sendData.envelopeMailFrom,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.scheduled) {
|
||||||
|
await handleScheduledSendCreated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the original message as $answered / $forwarded so the standard
|
// Mark the original message as $answered / $forwarded so the standard
|
||||||
// viewer and list reflect the action (same behaviour as inline compose).
|
// viewer and list reflect the action (same behaviour as inline compose).
|
||||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
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);
|
console.error('Failed to send email:', error);
|
||||||
toast.error(t('notifications.error_sending'));
|
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(() => {
|
const handleClose = useCallback(() => {
|
||||||
closeTab(tabIdRef.current);
|
closeTab(tabIdRef.current);
|
||||||
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
initialDraftText={data.initialDraftText}
|
initialDraftText={data.initialDraftText}
|
||||||
initialData={data.initialData}
|
initialData={data.initialData}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onScheduledSendCreated={handleScheduledSendCreated}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onDiscardDraft={handleDiscardDraft}
|
onDiscardDraft={handleDiscardDraft}
|
||||||
onSaveState={handleSaveState}
|
onSaveState={handleSaveState}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||||
import type { Email } from "@/lib/jmap/types";
|
import type { Email } from "@/lib/jmap/types";
|
||||||
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
|
||||||
interface ProEmailTabBodyProps {
|
interface ProEmailTabBodyProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -104,7 +105,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
replyTo: buildReplyContext(email),
|
replyTo: buildReplyContext(email),
|
||||||
sourceEmailId: email.id,
|
sourceEmailId: email.id,
|
||||||
initialDraftText: draftText,
|
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]);
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
mode: 'replyAll',
|
mode: 'replyAll',
|
||||||
replyTo: buildReplyContext(email),
|
replyTo: buildReplyContext(email),
|
||||||
sourceEmailId: email.id,
|
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]);
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
mode: 'forward',
|
mode: 'forward',
|
||||||
replyTo: buildReplyContext(email),
|
replyTo: buildReplyContext(email),
|
||||||
sourceEmailId: email.id,
|
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]);
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Download } from "lucide-react";
|
import { X, Download } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
@@ -16,6 +18,7 @@ export function PWAInstallPrompt() {
|
|||||||
useState<BeforeInstallPromptEvent | null>(null);
|
useState<BeforeInstallPromptEvent | null>(null);
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||||
|
const t = useTranslations("pwa_install");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem(DISMISSED_KEY)) return;
|
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||||
@@ -58,7 +61,8 @@ export function PWAInstallPrompt() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoSrc = appLogoLightUrl || faviconUrl;
|
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
|
||||||
|
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
|
||||||
|
|
||||||
return (
|
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">
|
<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 && (
|
{logoSrc && (
|
||||||
<img
|
<img
|
||||||
src={appLogoDarkUrl || faviconUrl}
|
src={darkLogoSrc}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||||
Install {appName}
|
{t("title", { appName })}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -103,20 +107,20 @@ export function PWAInstallPrompt() {
|
|||||||
onClick={handleDismiss}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDismissForever}
|
onClick={handleDismissForever}
|
||||||
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
|||||||
import { useUpdateStore } from '@/stores/update-store';
|
import { useUpdateStore } from '@/stores/update-store';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import { SpamSiegeGame } from './spam-siege-game';
|
import { SpamSiegeGame } from './spam-siege-game';
|
||||||
|
|
||||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
@@ -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">
|
<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">
|
<div className="shrink-0">
|
||||||
<img
|
<img
|
||||||
src="/branding/Bulwark_Logo_Color.svg"
|
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
|
||||||
alt="Bulwark"
|
alt="Bulwark"
|
||||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src="/branding/Bulwark_Logo_White.svg"
|
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
|
||||||
alt="Bulwark"
|
alt="Bulwark"
|
||||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
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 { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
|
||||||
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
|
|
||||||
];
|
|
||||||
|
|
||||||
function CalendarColorPicker({
|
function CalendarColorPicker({
|
||||||
value,
|
value,
|
||||||
@@ -182,6 +164,8 @@ export function CalendarManagementSettings() {
|
|||||||
const tImport = useTranslations('calendar.import');
|
const tImport = useTranslations('calendar.import');
|
||||||
const tSub = useTranslations('calendar.subscription');
|
const tSub = useTranslations('calendar.subscription');
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Load calendars if not yet loaded
|
// Load calendars if not yet loaded
|
||||||
@@ -329,6 +313,15 @@ export function CalendarManagementSettings() {
|
|||||||
|
|
||||||
const handleColorChange = async (calendarId: string, color: string) => {
|
const handleColorChange = async (calendarId: string, color: string) => {
|
||||||
if (!client) return;
|
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 {
|
try {
|
||||||
await updateCalendar(client, calendarId, { color });
|
await updateCalendar(client, calendarId, { color });
|
||||||
toast.success(t('color_updated'));
|
toast.success(t('color_updated'));
|
||||||
@@ -396,7 +389,7 @@ export function CalendarManagementSettings() {
|
|||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
{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) {
|
if (editingId === cal.id) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
|
|
||||||
export function CalendarSettings() {
|
export function CalendarSettings() {
|
||||||
const t = useTranslations('calendar.settings');
|
const t = useTranslations('calendar.settings');
|
||||||
const tViews = useTranslations('calendar.views');
|
const tViews = useTranslations('calendar.views');
|
||||||
const tDays = useTranslations('calendar.days');
|
|
||||||
|
|
||||||
const { viewMode, setViewMode } = useCalendarStore();
|
const { viewMode, setViewMode } = useCalendarStore();
|
||||||
const {
|
const {
|
||||||
timeFormat,
|
|
||||||
firstDayOfWeek,
|
|
||||||
showTimeInMonthView,
|
showTimeInMonthView,
|
||||||
showWeekNumbers,
|
showWeekNumbers,
|
||||||
enableCalendarTasks,
|
enableCalendarTasks,
|
||||||
@@ -40,28 +37,6 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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
|
<SettingItem
|
||||||
label={t('show_time_in_month_view')}
|
label={t('show_time_in_month_view')}
|
||||||
description={t('show_time_in_month_view_desc')}
|
description={t('show_time_in_month_view_desc')}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import type { SendDelaySeconds } from '@/stores/settings-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -22,11 +24,16 @@ export function ComposingSettings() {
|
|||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
attachmentReminderEnabled,
|
attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords,
|
attachmentReminderKeywords,
|
||||||
|
sendDelaySeconds,
|
||||||
subAddressDelimiter,
|
subAddressDelimiter,
|
||||||
signaturePosition,
|
signaturePosition,
|
||||||
signatureSeparatorEnabled,
|
signatureSeparatorEnabled,
|
||||||
|
requestReadReceiptDefault,
|
||||||
|
readReceiptResponse,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -37,6 +44,24 @@ export function ComposingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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')}>
|
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
||||||
<Select
|
<Select
|
||||||
value={signaturePosition}
|
value={signaturePosition}
|
||||||
@@ -55,6 +80,25 @@ export function ComposingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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
|
<SettingItem
|
||||||
label={t('sub_address_delimiter.label')}
|
label={t('sub_address_delimiter.label')}
|
||||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||||
|
|||||||
@@ -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 { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface SampleFile {
|
interface SampleFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
{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 ? (
|
) : settings.showIcons ? (
|
||||||
getPreviewIcon(file, settings.coloredIcons, "sm")
|
getPreviewIcon(file, settings.coloredIcons, "sm")
|
||||||
) : null}
|
) : null}
|
||||||
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
{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 ? (
|
) : settings.showIcons ? (
|
||||||
getPreviewIcon(file, settings.coloredIcons, "lg")
|
getPreviewIcon(file, settings.coloredIcons, "lg")
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
|
|||||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||||
const field = t(`condition_fields.${c.field}`);
|
const field = t(`condition_fields.${c.field}`);
|
||||||
const comparator = t(`comparators.${c.comparator}`);
|
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");
|
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="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="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||||
<span className="text-muted-foreground">{comparator}</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>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,100 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
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() {
|
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 (
|
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')}>
|
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</SettingItem>
|
</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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { useAccountStore } from '@/stores/account-store';
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
|
||||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||||
@@ -115,9 +117,11 @@ function MailLayoutPreview({
|
|||||||
export function LayoutSettings() {
|
export function LayoutSettings() {
|
||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
const tEmail = useTranslations('settings.email_behavior');
|
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 { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
const accounts = useAccountStore(s => s.accounts);
|
const accounts = useAccountStore(s => s.accounts);
|
||||||
|
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||||
|
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -177,10 +181,11 @@ export function LayoutSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{accounts.length > 1 && (
|
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('unified_mailbox.label')}
|
label={t('unified_mailbox.label')}
|
||||||
description={t('unified_mailbox.description')}
|
description={t('unified_mailbox.description')}
|
||||||
|
locked={isSettingLocked('enableUnifiedMailbox')}
|
||||||
>
|
>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={enableUnifiedMailbox}
|
checked={enableUnifiedMailbox}
|
||||||
@@ -189,6 +194,21 @@ export function LayoutSettings() {
|
|||||||
</SettingItem>
|
</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')}>
|
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={proInterface}
|
checked={proInterface}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
|
|||||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
|
import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
@@ -40,16 +40,30 @@ export function NotificationSettings() {
|
|||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
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 client = useAuthStore((s) => s.client);
|
||||||
const username = useAuthStore((s) => s.username);
|
const username = useAuthStore((s) => s.username);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
const supported = typeof window !== 'undefined' && isWebPushSupported();
|
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>(
|
const [pushStatus, setPushStatus] = useState<PushStatus>(
|
||||||
supported ? { kind: 'idle' } : { kind: 'unsupported' },
|
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(() => {
|
useEffect(() => {
|
||||||
if (!supported) return;
|
if (!supported) return;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
@@ -125,12 +139,17 @@ export function NotificationSettings() {
|
|||||||
<SettingsSection title={t('push.title')} description={t('push.description')}>
|
<SettingsSection title={t('push.title')} description={t('push.description')}>
|
||||||
<div className="rounded-md border p-4 space-y-3">
|
<div className="rounded-md border p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between gap-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')}
|
{t('push.relay_label')}
|
||||||
|
{pushRelayLocked && (
|
||||||
|
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<PushStatusBadge status={pushStatus} t={t} />
|
<PushStatusBadge status={pushStatus} t={t} />
|
||||||
</div>
|
</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
|
<input
|
||||||
id="push-relay-url"
|
id="push-relay-url"
|
||||||
type="url"
|
type="url"
|
||||||
@@ -140,7 +159,8 @@ export function NotificationSettings() {
|
|||||||
value={relayUrl}
|
value={relayUrl}
|
||||||
onChange={(e) => setRelayUrl(e.target.value)}
|
onChange={(e) => setRelayUrl(e.target.value)}
|
||||||
placeholder={t('push.relay_placeholder')}
|
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"
|
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -120,9 +120,10 @@ export function ReadingSettings() {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={deleteAction}
|
value={deleteAction}
|
||||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
|
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'trash', label: t('delete_action.trash') },
|
{ 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') },
|
{ value: 'permanent', label: t('delete_action.permanent') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
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 { toast } from '@/stores/toast-store';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
|
||||||
@@ -14,6 +14,18 @@ export function ThemesSettings() {
|
|||||||
const themePolicy = getThemePolicy();
|
const themePolicy = getThemePolicy();
|
||||||
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
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
|
// Filter out themes disabled by admin policy
|
||||||
const visibleThemes = installedThemes.filter(
|
const visibleThemes = installedThemes.filter(
|
||||||
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
|
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
|
||||||
@@ -36,6 +48,9 @@ export function ThemesSettings() {
|
|||||||
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
||||||
|
|
||||||
const handleActivate = (id: string | null) => {
|
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) {
|
if (forcedThemeId && id !== forcedThemeId) {
|
||||||
const forcedTheme = installedThemes.find((theme) => theme.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`);
|
toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`);
|
||||||
@@ -61,6 +76,9 @@ export function ThemesSettings() {
|
|||||||
<ThemeCard
|
<ThemeCard
|
||||||
name="Default"
|
name="Default"
|
||||||
author="Bulwark"
|
author="Bulwark"
|
||||||
|
isDefaultTheme
|
||||||
|
variants={['light', 'dark']}
|
||||||
|
isDark={isDark}
|
||||||
isActive={activeThemeId === null}
|
isActive={activeThemeId === null}
|
||||||
isBuiltIn
|
isBuiltIn
|
||||||
isDefault={!themePolicy.defaultThemeId}
|
isDefault={!themePolicy.defaultThemeId}
|
||||||
@@ -77,6 +95,8 @@ export function ThemesSettings() {
|
|||||||
name={theme.name}
|
name={theme.name}
|
||||||
author={theme.author}
|
author={theme.author}
|
||||||
preview={theme.preview}
|
preview={theme.preview}
|
||||||
|
css={theme.css}
|
||||||
|
isDark={isDark}
|
||||||
isActive={activeThemeId === theme.id}
|
isActive={activeThemeId === theme.id}
|
||||||
isBuiltIn={theme.builtIn}
|
isBuiltIn={theme.builtIn}
|
||||||
isDefault={themePolicy.defaultThemeId === theme.id}
|
isDefault={themePolicy.defaultThemeId === theme.id}
|
||||||
@@ -98,6 +118,9 @@ interface ThemeCardProps {
|
|||||||
name: string;
|
name: string;
|
||||||
author: string;
|
author: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
css?: string;
|
||||||
|
isDark?: boolean;
|
||||||
|
isDefaultTheme?: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isBuiltIn: boolean;
|
isBuiltIn: boolean;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
@@ -107,7 +130,8 @@ interface ThemeCardProps {
|
|||||||
onActivate: () => void;
|
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 (
|
return (
|
||||||
<div data-search-label={name} className="relative">
|
<div data-search-label={name} className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -122,12 +146,13 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
|
|||||||
disabled && !isActive && 'hover:border-border'
|
disabled && !isActive && 'hover:border-border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Preview / Placeholder */}
|
{/* Preview: marketplace image when shipped, otherwise a mini app
|
||||||
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
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 ? (
|
{preview ? (
|
||||||
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -162,3 +187,141 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
|
|||||||
</div>
|
</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 { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||||
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
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 customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
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 imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||||
|
|
||||||
@@ -269,6 +270,15 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
|||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={handleImgError}
|
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()
|
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 */
|
/** Latvia – Maroon, White, Maroon horizontal */
|
||||||
export function FlagLV(props: FlagProps) {
|
export function FlagLV(props: FlagProps) {
|
||||||
return (
|
return (
|
||||||
@@ -219,6 +230,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
|||||||
en: FlagGB,
|
en: FlagGB,
|
||||||
es: FlagES,
|
es: FlagES,
|
||||||
fr: FlagFR,
|
fr: FlagFR,
|
||||||
|
hu: FlagHU,
|
||||||
it: FlagIT,
|
it: FlagIT,
|
||||||
ja: FlagJP,
|
ja: FlagJP,
|
||||||
ko: FlagKR,
|
ko: FlagKR,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const languages = [
|
|||||||
{ value: 'es', label: 'Español' },
|
{ value: 'es', label: 'Español' },
|
||||||
{ value: 'fr', label: 'Français' },
|
{ value: 'fr', label: 'Français' },
|
||||||
{ value: 'it', label: 'Italiano' },
|
{ value: 'it', label: 'Italiano' },
|
||||||
|
{ value: 'hu', label: 'Magyar' },
|
||||||
{ value: 'lv', label: 'Latviešu' },
|
{ value: 'lv', label: 'Latviešu' },
|
||||||
{ value: 'nl', label: 'Nederlands' },
|
{ value: 'nl', label: 'Nederlands' },
|
||||||
{ value: 'pl', label: 'Polski' },
|
{ value: 'pl', label: 'Polski' },
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ export function useAttachmentDrag(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
|
// `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
|
||||||
// drop and writes a real file at the destination.
|
// raw - URL-encoding it lands literally on disk (`%20` instead of a
|
||||||
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
|
// space). Callers are expected to sanitise reserved chars (`:` etc.)
|
||||||
|
// beforehand.
|
||||||
|
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
|
||||||
e.dataTransfer.effectAllowed = "copyMove";
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
},
|
},
|
||||||
[source.name, source.type, prefetch],
|
[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 { Email } from "@/lib/jmap/types";
|
||||||
|
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
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 {
|
interface UseEmailDragOptions {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -15,6 +26,7 @@ interface UseEmailDragOptions {
|
|||||||
interface UseEmailDragReturn {
|
interface UseEmailDragReturn {
|
||||||
dragHandlers: {
|
dragHandlers: {
|
||||||
draggable: boolean;
|
draggable: boolean;
|
||||||
|
onPointerEnter?: () => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
};
|
};
|
||||||
@@ -44,10 +56,153 @@ function createDragPreview(count: number): HTMLElement {
|
|||||||
return preview;
|
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 {
|
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||||
const { selectedEmailIds, emails } = useEmailStore();
|
const { selectedEmailIds, emails } = useEmailStore();
|
||||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
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>) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
// Determine which emails to drag:
|
// Determine which emails to drag:
|
||||||
@@ -59,8 +214,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||||
: threadEmails || [email];
|
: threadEmails || [email];
|
||||||
|
|
||||||
// Set data transfer
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
e.dataTransfer.setData(
|
e.dataTransfer.setData(
|
||||||
"application/x-email-ids",
|
"application/x-email-ids",
|
||||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
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(", ")
|
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
|
// Create custom drag image
|
||||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||||
@@ -80,10 +268,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
});
|
});
|
||||||
|
|
||||||
startDrag(emailsToDrag, sourceMailboxId);
|
startDrag(emailsToDrag, sourceMailboxId);
|
||||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
|
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
endDrag();
|
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]);
|
}, [endDrag]);
|
||||||
|
|
||||||
// Check if this specific email is being dragged
|
// Check if this specific email is being dragged
|
||||||
@@ -94,6 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
||||||
: {
|
: {
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
|
||||||
onDragStart: handleDragStart,
|
onDragStart: handleDragStart,
|
||||||
onDragEnd: handleDragEnd,
|
onDragEnd: handleDragEnd,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ export const KEYBOARD_SHORTCUTS = {
|
|||||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||||
],
|
],
|
||||||
composer: [
|
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: "t", description: "shortcuts.composer.template_picker" },
|
||||||
|
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+31
-19
@@ -81,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||||
if (mailbox.id.startsWith("shared-")) return false;
|
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;
|
return true;
|
||||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
}, [isDragging, mailbox, sourceMailboxId]);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -154,18 +142,42 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
bySource.get(srcAccountId)!.push(id);
|
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 sourceAccountIds = Array.from(bySource.keys());
|
||||||
const isCrossAccount =
|
|
||||||
!!destAccountId &&
|
const isJmapCrossAccount =
|
||||||
|
!!sourceJmapAccountId &&
|
||||||
|
!!destJmapAccountId &&
|
||||||
|
sourceJmapAccountId !== destJmapAccountId;
|
||||||
|
const isLocalCrossAccount =
|
||||||
!mailbox.isShared &&
|
!mailbox.isShared &&
|
||||||
|
!!destAccountId &&
|
||||||
sourceAccountIds.some((src) => src !== destAccountId);
|
sourceAccountIds.some((src) => src !== destAccountId);
|
||||||
|
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
|
||||||
|
|
||||||
if (isCrossAccount) {
|
if (isCrossAccount) {
|
||||||
// JMAP can't natively move an email between primary accounts, so the
|
if (!destAccountId) {
|
||||||
// store reuploads each source blob into the destination account and
|
throw new Error('Could not resolve destination account');
|
||||||
// then deletes the original.
|
}
|
||||||
|
// 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;
|
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 {
|
} else {
|
||||||
// Single-account or same-account-shared move: bulk JMAP request.
|
// Single-account or same-account-shared move: bulk JMAP request.
|
||||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||||
|
|||||||
+14
-2
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
|||||||
| 'always'
|
| 'always'
|
||||||
| 'as-needed';
|
| '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({
|
export const routing = defineRouting({
|
||||||
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
locales: SUPPORTED_LOCALES,
|
||||||
defaultLocale: 'en',
|
defaultLocale: resolvedDefaultLocale,
|
||||||
localePrefix
|
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';
|
import { BUILTIN_THEMES } from '../builtin-themes';
|
||||||
|
|
||||||
describe('BUILTIN_THEMES', () => {
|
describe('BUILTIN_THEMES', () => {
|
||||||
it('contains exactly 3 themes', () => {
|
it('contains exactly 6 themes', () => {
|
||||||
expect(BUILTIN_THEMES).toHaveLength(3);
|
expect(BUILTIN_THEMES).toHaveLength(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('all themes have required fields', () => {
|
it('all themes have required fields', () => {
|
||||||
@@ -43,6 +43,8 @@ describe('BUILTIN_THEMES', () => {
|
|||||||
expect(names).toContain('Nord');
|
expect(names).toContain('Nord');
|
||||||
expect(names).toContain('Catppuccin');
|
expect(names).toContain('Catppuccin');
|
||||||
expect(names).toContain('Solarized');
|
expect(names).toContain('Solarized');
|
||||||
|
expect(names).toContain('Roundcube Elastic');
|
||||||
|
expect(names).toContain('Aurora Glass');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('theme IDs are unique', () => {
|
it('theme IDs are unique', () => {
|
||||||
|
|||||||
@@ -36,16 +36,29 @@ describe('config API route', () => {
|
|||||||
delete process.env.LOGIN_IMPRINT_URL;
|
delete process.env.LOGIN_IMPRINT_URL;
|
||||||
delete process.env.LOGIN_PRIVACY_POLICY_URL;
|
delete process.env.LOGIN_PRIVACY_POLICY_URL;
|
||||||
delete process.env.LOGIN_WEBSITE_URL;
|
delete process.env.LOGIN_WEBSITE_URL;
|
||||||
|
delete process.env.DOMAIN_BRANDING;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = { ...originalEnv };
|
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
|
// Re-import to pick up env changes
|
||||||
const { GET } = await import('@/app/api/config/route');
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,4 +205,70 @@ describe('config API route', () => {
|
|||||||
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
|
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
|
||||||
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getSecurityStatus,
|
getSecurityStatus,
|
||||||
parseSpamLLM,
|
parseSpamLLM,
|
||||||
extractListHeaders,
|
extractListHeaders,
|
||||||
|
isAuthenticationSpoofed,
|
||||||
} from '../email-headers';
|
} from '../email-headers';
|
||||||
|
|
||||||
describe('parseAuthenticationResults', () => {
|
describe('parseAuthenticationResults', () => {
|
||||||
@@ -52,6 +53,66 @@ describe('parseAuthenticationResults', () => {
|
|||||||
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
|
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
|
||||||
expect(result.spf?.result).toBe('softfail');
|
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', () => {
|
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;
|
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 {
|
interface JMAPMethodCall {
|
||||||
0: string;
|
0: string;
|
||||||
1: Record<string, unknown>;
|
1: Record<string, unknown>;
|
||||||
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
|
|||||||
payload = {
|
payload = {
|
||||||
methodResponses: [
|
methodResponses: [
|
||||||
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
|
['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.inReplyTo).toEqual(['real@example.com']);
|
||||||
expect(draft.references).toBeUndefined();
|
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' } } })]),
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
|
|||||||
expect(consoleSpy).toHaveBeenCalled();
|
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 () => {
|
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||||
ok: true,
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Calendar } from '@/lib/jmap/types';
|
||||||
|
import {
|
||||||
|
CALENDAR_COLORS,
|
||||||
|
sharedCalendarColorKey,
|
||||||
|
pickUnusedCalendarColor,
|
||||||
|
} from '../shared-calendar-colors';
|
||||||
|
|
||||||
|
function makeCal(overrides: Partial<Calendar>): Calendar {
|
||||||
|
return {
|
||||||
|
id: 'cal-1',
|
||||||
|
name: 'Cal',
|
||||||
|
description: null,
|
||||||
|
color: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: {} as Calendar['myRights'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sharedCalendarColorKey', () => {
|
||||||
|
it('is built from local account, JMAP account, and the original id', () => {
|
||||||
|
const cal = makeCal({
|
||||||
|
id: 'acct-9:cal-7',
|
||||||
|
originalId: 'cal-7',
|
||||||
|
accountId: 'acct-9',
|
||||||
|
localAccountId: 'slot-2',
|
||||||
|
});
|
||||||
|
expect(sharedCalendarColorKey(cal)).toBe('slot-2|acct-9|cal-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable regardless of the Pro-shell id prefix', () => {
|
||||||
|
// Same underlying calendar, shown once under the active account (bare id)
|
||||||
|
// and once cross-account (prefixed id) - both must map to one key.
|
||||||
|
const active = makeCal({ id: 'acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
|
||||||
|
const prefixed = makeCal({ id: 'slot-2::acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
|
||||||
|
expect(sharedCalendarColorKey(active)).toBe(sharedCalendarColorKey(prefixed));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the id when originalId is absent', () => {
|
||||||
|
const cal = makeCal({ id: 'cal-7', accountId: 'acct-9' });
|
||||||
|
expect(sharedCalendarColorKey(cal)).toBe('|acct-9|cal-7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickUnusedCalendarColor', () => {
|
||||||
|
it('returns a palette color not present in usedColors', () => {
|
||||||
|
const used = CALENDAR_COLORS.slice(0, CALENDAR_COLORS.length - 1);
|
||||||
|
const picked = pickUnusedCalendarColor(used);
|
||||||
|
expect(picked).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores case when comparing used colors', () => {
|
||||||
|
const used = CALENDAR_COLORS.slice(0, -1).map((c) => c.toUpperCase());
|
||||||
|
expect(pickUnusedCalendarColor(used)).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns a palette color once every color is taken', () => {
|
||||||
|
expect(CALENDAR_COLORS).toContain(pickUnusedCalendarColor(CALENDAR_COLORS));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always returns a valid palette color for a small used set', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const picked = pickUnusedCalendarColor(['#3b82f6']);
|
||||||
|
expect(CALENDAR_COLORS).toContain(picked);
|
||||||
|
expect(picked).not.toBe('#3b82f6');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendHtmlSignature,
|
||||||
appendPlainTextSignature,
|
appendPlainTextSignature,
|
||||||
getPlainTextSignature,
|
getPlainTextSignature,
|
||||||
hasMeaningfulHtmlBody,
|
hasMeaningfulHtmlBody,
|
||||||
@@ -27,6 +28,27 @@ describe('signature-utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('appendHtmlSignature', () => {
|
||||||
|
it('appends a sanitized html signature, preserving formatting', () => {
|
||||||
|
expect(appendHtmlSignature('<div>Hello</div>', { htmlSignature: '<strong>Alice</strong>' }))
|
||||||
|
.toBe('<div>Hello</div><br><br>-- <br><strong>Alice</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes and appends a text signature when no html signature exists', () => {
|
||||||
|
expect(appendHtmlSignature('<div>Hello</div>', { textSignature: 'Alice\nEng' }))
|
||||||
|
.toBe('<div>Hello</div><br><br>-- <br>Alice<br>Eng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the separator marker when disabled', () => {
|
||||||
|
expect(appendHtmlSignature('<div>Hi</div>', { htmlSignature: '<strong>A</strong>' }, { separator: false }))
|
||||||
|
.toBe('<div>Hi</div><br><br><strong>A</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the body untouched when no signature exists', () => {
|
||||||
|
expect(appendHtmlSignature('<div>Hi</div>', {})).toBe('<div>Hi</div>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hasMeaningfulHtmlBody', () => {
|
describe('hasMeaningfulHtmlBody', () => {
|
||||||
it('prefers html bodies that preserve signature formatting', () => {
|
it('prefers html bodies that preserve signature formatting', () => {
|
||||||
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Per-domain branding overrides: schema, parsing, host extraction, and match.
|
||||||
|
*
|
||||||
|
* The webmail can be served on multiple hostnames (e.g. mail1.example.com,
|
||||||
|
* mail2.other.com). Each hostname can override a subset of branding fields;
|
||||||
|
* unset fields fall back to the global admin/env/default value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
/** Config keys that can be overridden per domain. */
|
||||||
|
export const BRANDING_OVERRIDE_KEYS = [
|
||||||
|
'appName',
|
||||||
|
'appShortName',
|
||||||
|
'appDescription',
|
||||||
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
|
'pwaThemeColor',
|
||||||
|
'pwaBackgroundColor',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
'loginCompanyName',
|
||||||
|
'loginImprintUrl',
|
||||||
|
'loginPrivacyPolicyUrl',
|
||||||
|
'loginWebsiteUrl',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number];
|
||||||
|
|
||||||
|
export interface DomainBrandingEntry {
|
||||||
|
/**
|
||||||
|
* Hostname this entry applies to. Either an exact host like
|
||||||
|
* "mail.example.com" or a wildcard like "*.example.com" (matches any
|
||||||
|
* direct or deeper subdomain). Case-insensitive; trailing dots are
|
||||||
|
* stripped on parse.
|
||||||
|
*/
|
||||||
|
host: string;
|
||||||
|
appName?: string;
|
||||||
|
appShortName?: string;
|
||||||
|
appDescription?: string;
|
||||||
|
faviconUrl?: string;
|
||||||
|
pwaIconUrl?: string;
|
||||||
|
pwaScreenshotMobileUrl?: string;
|
||||||
|
pwaScreenshotDesktopUrl?: string;
|
||||||
|
pwaThemeColor?: string;
|
||||||
|
pwaBackgroundColor?: string;
|
||||||
|
appLogoLightUrl?: string;
|
||||||
|
appLogoDarkUrl?: string;
|
||||||
|
loginLogoLightUrl?: string;
|
||||||
|
loginLogoDarkUrl?: string;
|
||||||
|
loginCompanyName?: string;
|
||||||
|
loginImprintUrl?: string;
|
||||||
|
loginPrivacyPolicyUrl?: string;
|
||||||
|
loginWebsiteUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards
|
||||||
|
// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo,
|
||||||
|
// and embedded wildcards.
|
||||||
|
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||||
|
|
||||||
|
function normalizeHost(host: string): string {
|
||||||
|
return host.trim().toLowerCase().replace(/\.+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */
|
||||||
|
export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
let value = raw;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: DomainBrandingEntry[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const rec = item as Record<string, unknown>;
|
||||||
|
const rawHost = typeof rec.host === 'string' ? rec.host : '';
|
||||||
|
const host = normalizeHost(rawHost);
|
||||||
|
if (!host || !HOST_RE.test(host)) continue;
|
||||||
|
if (seen.has(host)) continue;
|
||||||
|
seen.add(host);
|
||||||
|
|
||||||
|
const entry: DomainBrandingEntry = { host };
|
||||||
|
const writable = entry as unknown as Record<string, string>;
|
||||||
|
for (const key of BRANDING_OVERRIDE_KEYS) {
|
||||||
|
const v = rec[key];
|
||||||
|
if (typeof v === 'string' && v.length > 0) {
|
||||||
|
writable[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeadersLike = Headers | { get(name: string): string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the request's host, preferring X-Forwarded-Host (first entry if
|
||||||
|
* comma-separated) over Host. Strips the port. Returns null when no usable
|
||||||
|
* host header is set.
|
||||||
|
*/
|
||||||
|
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
|
||||||
|
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
|
||||||
|
const raw = headers.get('x-forwarded-host') || headers.get('host');
|
||||||
|
if (!raw) return null;
|
||||||
|
const first = raw.split(',')[0]?.trim();
|
||||||
|
if (!first) return null;
|
||||||
|
return normalizeHost(first.split(':')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the entry whose host matches `host`. Exact match always wins; among
|
||||||
|
* wildcards the longest (most-specific) suffix wins. Returns {} when no
|
||||||
|
* entry matches.
|
||||||
|
*/
|
||||||
|
export function matchDomainBranding(
|
||||||
|
host: string | null,
|
||||||
|
entries: DomainBrandingEntry[],
|
||||||
|
): Partial<DomainBrandingEntry> {
|
||||||
|
if (!host || entries.length === 0) return {};
|
||||||
|
const lower = normalizeHost(host);
|
||||||
|
let wildcardMatch: DomainBrandingEntry | undefined;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.host === lower) return entry;
|
||||||
|
if (entry.host.startsWith('*.')) {
|
||||||
|
const suffix = entry.host.slice(1); // ".example.com"
|
||||||
|
if (lower.endsWith(suffix) && lower.length > suffix.length) {
|
||||||
|
if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) {
|
||||||
|
wildcardMatch = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wildcardMatch ?? {};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user