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 |
@@ -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"}]
|
||||||
|
|||||||
@@ -225,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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useConfig } from "@/hooks/use-config";
|
|||||||
import { apiFetch, getPathPrefix, withBasePath } 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");
|
||||||
|
|||||||
+450
-108
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
Info,
|
Info,
|
||||||
Bug,
|
Bug,
|
||||||
|
SwatchBook,
|
||||||
Download,
|
Download,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -145,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,
|
||||||
};
|
};
|
||||||
@@ -335,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) {
|
||||||
@@ -360,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);
|
||||||
@@ -582,6 +596,7 @@ 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' },
|
||||||
@@ -607,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 }] : []),
|
||||||
];
|
];
|
||||||
@@ -933,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,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>
|
||||||
|
|||||||
@@ -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)">
|
||||||
|
|||||||
+27
-5
@@ -1,13 +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 { 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"],
|
||||||
@@ -27,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",
|
||||||
@@ -48,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 || "";
|
||||||
|
|
||||||
@@ -92,7 +115,6 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
{children}
|
{children}
|
||||||
<PWAInstallPrompt />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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;
|
||||||
|
|||||||
+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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +1760,30 @@ 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
|
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
|
||||||
// draft. Scoped to events whose target lives inside this composer's
|
// draft. Scoped to events whose target lives inside this composer's
|
||||||
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
||||||
@@ -1599,6 +1809,7 @@ export function EmailComposer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cleanClose = () => {
|
const cleanClose = () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -1607,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);
|
||||||
@@ -1617,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);
|
||||||
@@ -1636,6 +1849,48 @@ 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 ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
@@ -1650,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 && (
|
||||||
@@ -1970,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">
|
||||||
@@ -1990,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(
|
||||||
@@ -2012,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"
|
||||||
@@ -2025,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)}
|
||||||
@@ -2039,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
|
||||||
@@ -2077,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 && (
|
||||||
<>
|
<>
|
||||||
@@ -2103,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>
|
||||||
|
|
||||||
@@ -2115,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>
|
||||||
|
|
||||||
@@ -2162,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
|
||||||
@@ -2238,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>
|
||||||
@@ -2277,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"
|
||||||
@@ -2485,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 && (
|
||||||
@@ -2501,4 +2873,4 @@ function RecipientChipInput({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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";
|
||||||
@@ -27,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;
|
||||||
@@ -40,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({
|
||||||
@@ -49,6 +56,8 @@ export function EmailList({
|
|||||||
onEmailDoubleClick,
|
onEmailDoubleClick,
|
||||||
className,
|
className,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
hasMore,
|
||||||
|
isLoadingMoreItems,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
@@ -62,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();
|
||||||
@@ -96,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();
|
||||||
@@ -108,6 +122,8 @@ 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 footerHasMore = hasMore ?? hasMoreEmails;
|
||||||
|
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||||
@@ -219,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);
|
||||||
@@ -282,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">
|
||||||
@@ -406,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>
|
||||||
) : (
|
) : (
|
||||||
@@ -468,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>
|
||||||
@@ -509,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 () => {
|
||||||
|
|||||||
@@ -66,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";
|
||||||
@@ -82,6 +83,8 @@ 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";
|
||||||
@@ -126,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;
|
||||||
@@ -875,6 +881,9 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
onCompose,
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
@@ -884,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');
|
||||||
@@ -903,6 +913,7 @@ 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(), []);
|
||||||
@@ -934,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) => ({
|
||||||
@@ -947,6 +960,29 @@ export function EmailViewer({
|
|||||||
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 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();
|
||||||
@@ -2160,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,
|
||||||
@@ -2860,7 +2899,10 @@ 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: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
|
@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; }
|
||||||
@@ -3219,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 (
|
||||||
@@ -3358,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"
|
||||||
@@ -3370,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"
|
||||||
@@ -3412,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
|
||||||
@@ -3869,6 +4034,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3879,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",
|
||||||
@@ -4060,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>
|
||||||
@@ -4651,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')} />
|
||||||
)}
|
)}
|
||||||
@@ -4817,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>
|
||||||
@@ -4852,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">
|
||||||
@@ -4909,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">
|
||||||
@@ -5289,7 +5519,7 @@ 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
|
||||||
|
|||||||
@@ -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,6 +67,7 @@ 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);
|
||||||
@@ -78,6 +79,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
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);
|
||||||
@@ -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,6 +400,7 @@ 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;
|
||||||
@@ -381,6 +408,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
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);
|
||||||
@@ -646,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>
|
||||||
) : (
|
) : (
|
||||||
@@ -716,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>
|
||||||
|
|
||||||
@@ -752,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -900,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');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -938,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1027,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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1067,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,6 +1,7 @@
|
|||||||
"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";
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
@@ -17,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;
|
||||||
@@ -84,17 +86,17 @@ export function PWAInstallPrompt() {
|
|||||||
)}
|
)}
|
||||||
<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>
|
||||||
@@ -105,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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -270,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' },
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ 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" },
|
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -173,7 +173,11 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
// import call to target the owner's JMAP account.
|
// import call to target the owner's JMAP account.
|
||||||
const jmapDestId = mailbox.originalId || mailbox.id;
|
const jmapDestId = mailbox.originalId || mailbox.id;
|
||||||
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
||||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
|
// 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);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ 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', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
|
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
|
// 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
|
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||||
|
|||||||
@@ -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' } } })]),
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ?? {};
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ async function migrateAdminJson(): Promise<boolean> {
|
|||||||
lastLogin: data.lastLogin ?? null,
|
lastLogin: data.lastLogin ?? null,
|
||||||
passwordChangedAt: data.passwordChangedAt ?? now,
|
passwordChangedAt: data.passwordChangedAt ?? now,
|
||||||
};
|
};
|
||||||
const configData: AdminConfigData = { passwordHash: data.passwordHash };
|
const configData: AdminConfigData = { passwordHash: data.passwordHash, passwordHashFile: undefined };
|
||||||
|
|
||||||
await ensureStateDir();
|
await ensureStateDir();
|
||||||
const statePath = getStatePath('admin-state.json');
|
const statePath = getStatePath('admin-state.json');
|
||||||
|
|||||||
+14
-4
@@ -9,6 +9,7 @@ import {
|
|||||||
assertWritable,
|
assertWritable,
|
||||||
} from './paths';
|
} from './paths';
|
||||||
import type { AdminConfigData, AdminStateData } from './types';
|
import type { AdminConfigData, AdminStateData } from './types';
|
||||||
|
import { readFileEnv } from '../read-file-env';
|
||||||
|
|
||||||
const SCRYPT_KEYLEN = 64;
|
const SCRYPT_KEYLEN = 64;
|
||||||
const SCRYPT_COST = 16384; // 2^14
|
const SCRYPT_COST = 16384; // 2^14
|
||||||
@@ -146,7 +147,7 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
||||||
cachedConfig = { passwordHash: hash };
|
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||||
cachedState = freshState();
|
cachedState = freshState();
|
||||||
await writeConfigData(cachedConfig);
|
await writeConfigData(cachedConfig);
|
||||||
await writeStateData(cachedState);
|
await writeStateData(cachedState);
|
||||||
@@ -165,7 +166,16 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
||||||
if (!cachedConfig) cachedConfig = await readConfigData();
|
if (!cachedConfig) cachedConfig = await readConfigData();
|
||||||
if (!cachedConfig) return false;
|
if (!cachedConfig) return false;
|
||||||
return verifyPassword(password, cachedConfig.passwordHash);
|
if (cachedConfig.passwordHash) {
|
||||||
|
return verifyPassword(password, cachedConfig.passwordHash);
|
||||||
|
} else if (cachedConfig.passwordHashFile) {
|
||||||
|
let passwordHash = readFileEnv(cachedConfig.passwordHashFile);
|
||||||
|
if (passwordHash) {
|
||||||
|
return verifyPassword(password, passwordHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.error('The admin password hash could neither be retrieved from passwordHash nor from passwordHashFile in admin.json');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,7 +186,7 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
|||||||
if (!valid) return false;
|
if (!valid) return false;
|
||||||
|
|
||||||
const hash = await hashPassword(newPassword);
|
const hash = await hashPassword(newPassword);
|
||||||
cachedConfig = { passwordHash: hash };
|
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||||
await writeConfigData(cachedConfig);
|
await writeConfigData(cachedConfig);
|
||||||
|
|
||||||
cachedState = {
|
cachedState = {
|
||||||
@@ -205,7 +215,7 @@ export async function setInitialAdminPassword(
|
|||||||
const existing = await readConfigData();
|
const existing = await readConfigData();
|
||||||
if (existing && !options.allowOverwrite) return false;
|
if (existing && !options.allowOverwrite) return false;
|
||||||
const hash = await hashPassword(newPassword);
|
const hash = await hashPassword(newPassword);
|
||||||
cachedConfig = { passwordHash: hash };
|
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||||
cachedState = freshState();
|
cachedState = freshState();
|
||||||
await writeConfigData(cachedConfig);
|
await writeConfigData(cachedConfig);
|
||||||
await writeStateData(cachedState);
|
await writeStateData(cachedState);
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export interface ServerPlugin {
|
|||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
configSchema?: Record<string, PluginConfigField>;
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
settingsSchema?: Record<string, PluginSettingsField>;
|
settingsSchema?: Record<string, PluginSettingsField>;
|
||||||
|
/**
|
||||||
|
* Optional per-locale translation tables (locale -> key -> string) declared
|
||||||
|
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
|
||||||
|
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
|
||||||
|
* default language.
|
||||||
|
*/
|
||||||
|
locales?: Record<string, Record<string, string>>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+12
-1
@@ -6,7 +6,8 @@
|
|||||||
* is config; mutable timestamps live in AdminStateData.
|
* is config; mutable timestamps live in AdminStateData.
|
||||||
*/
|
*/
|
||||||
export interface AdminConfigData {
|
export interface AdminConfigData {
|
||||||
passwordHash: string;
|
passwordHash: string | undefined;
|
||||||
|
passwordHashFile: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +107,10 @@ export interface SettingsPolicy {
|
|||||||
approvedPlugins: string[];
|
approvedPlugins: string[];
|
||||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||||
forceEnabledThemes: string[];
|
forceEnabledThemes: string[];
|
||||||
|
/** Web Push relay base URL shown to users. Empty means the built-in default. */
|
||||||
|
pushRelayUrl?: string;
|
||||||
|
/** When true, users cannot change pushRelayUrl in notification settings. */
|
||||||
|
pushRelayUrlLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_POLICY: SettingsPolicy = {
|
export const DEFAULT_POLICY: SettingsPolicy = {
|
||||||
@@ -116,6 +121,8 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
|||||||
forceEnabledPlugins: [],
|
forceEnabledPlugins: [],
|
||||||
approvedPlugins: [],
|
approvedPlugins: [],
|
||||||
forceEnabledThemes: [],
|
forceEnabledThemes: [],
|
||||||
|
pushRelayUrl: '',
|
||||||
|
pushRelayUrlLocked: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
@@ -130,12 +137,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||||
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
|
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
|
||||||
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
|
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
|
||||||
|
searchEngineIndexing: { envVar: 'SEARCH_ENGINE_INDEXING', type: 'boolean', defaultValue: false },
|
||||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||||
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
||||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||||
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||||||
|
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
|
||||||
|
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
|
||||||
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||||
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||||||
@@ -157,6 +167,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||||
|
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
|
||||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export function getSessionSecret(): string {
|
|||||||
if (fromFile) return fromFile;
|
if (fromFile) return fromFile;
|
||||||
|
|
||||||
const fromAdmin = configManager.get<string>('sessionSecret', '');
|
const fromAdmin = configManager.get<string>('sessionSecret', '');
|
||||||
return fromAdmin || '';
|
if (fromAdmin) return fromAdmin;
|
||||||
|
|
||||||
|
const fromAdminFile = readFileEnv(
|
||||||
|
configManager.get<string>('sessionSecretFile', ''),
|
||||||
|
);
|
||||||
|
return fromAdminFile || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasSessionSecret(): boolean {
|
export function hasSessionSecret(): boolean {
|
||||||
|
|||||||
@@ -328,6 +328,552 @@ const solarizedCSS = `
|
|||||||
--color-chart-5: #6c71c4;
|
--color-chart-5: #6c71c4;
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
// Roundcube "Elastic" skin recreation.
|
||||||
|
//
|
||||||
|
// Colour tokens are lifted from skins/elastic/styles/colors.less (CC BY-SA):
|
||||||
|
// accent #37beff font #27353a border #ddd
|
||||||
|
// error #ff5552 success #41b849 warning #ffd452
|
||||||
|
// task-menu #2f3a3f (always dark, both modes)
|
||||||
|
// list-select tint(#37beff, 90%) -> #ebf8ff
|
||||||
|
// Dark mode mirrors @color-dark-* (background #21292c, font #c5d1d3, …).
|
||||||
|
const elasticCSS = `
|
||||||
|
:root {
|
||||||
|
--color-border: #dddddd;
|
||||||
|
--color-input: #ced4da;
|
||||||
|
--color-ring: #37beff;
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #27353a;
|
||||||
|
--color-primary: #37beff;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #f4f4f4;
|
||||||
|
--color-secondary-foreground: #27353a;
|
||||||
|
--color-muted: #f4f4f4;
|
||||||
|
--color-muted-foreground: #737677;
|
||||||
|
--color-accent: #e6f6ff;
|
||||||
|
--color-accent-foreground: #0070a8;
|
||||||
|
--color-destructive: #ff5552;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #27353a;
|
||||||
|
--color-sidebar: #ffffff;
|
||||||
|
--color-sidebar-foreground: #27353a;
|
||||||
|
--color-sidebar-border: #dddddd;
|
||||||
|
--color-sidebar-accent: #ebf8ff;
|
||||||
|
--color-sidebar-accent-foreground: #27353a;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #27353a;
|
||||||
|
--color-success: #41b849;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #ffd452;
|
||||||
|
--color-warning-foreground: #27353a;
|
||||||
|
--color-info: #37beff;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
--color-selection: #ebf8ff;
|
||||||
|
--color-selection-foreground: #27353a;
|
||||||
|
--color-unread: #ffd452;
|
||||||
|
--color-chart-1: #37beff;
|
||||||
|
--color-chart-2: #41b849;
|
||||||
|
--color-chart-3: #ffd452;
|
||||||
|
--color-chart-4: #ff5552;
|
||||||
|
--color-chart-5: #9b59b6;
|
||||||
|
/* Elastic is a 14px Roboto skin with tighter list rows than Bulwark's default */
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--list-item-height: 40px;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--color-border: #4d6066;
|
||||||
|
--color-input: #4d6066;
|
||||||
|
--color-ring: #37beff;
|
||||||
|
--color-background: #21292c;
|
||||||
|
--color-foreground: #ffffff;
|
||||||
|
--color-primary: #37beff;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #2c373a;
|
||||||
|
--color-secondary-foreground: #ffffff;
|
||||||
|
--color-muted: #2c373a;
|
||||||
|
/* Roundcube keeps most text near the bright font colour, only true hints
|
||||||
|
dim out. Bulwark applies muted-foreground far more widely, so brighten it
|
||||||
|
toward @color-dark-font (#c5d1d3) to match Elastic's overall brightness. */
|
||||||
|
--color-muted-foreground: #b3bec1;
|
||||||
|
--color-accent: #374549;
|
||||||
|
--color-accent-foreground: #37beff;
|
||||||
|
--color-destructive: #ff5552;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-popover: #161b1d;
|
||||||
|
--color-popover-foreground: #ffffff;
|
||||||
|
--color-sidebar: #21292c;
|
||||||
|
--color-sidebar-foreground: #ffffff;
|
||||||
|
--color-sidebar-border: #4d6066;
|
||||||
|
--color-sidebar-accent: #374549;
|
||||||
|
--color-sidebar-accent-foreground: #c5d1d3;
|
||||||
|
--color-card: #1a2225;
|
||||||
|
--color-card-foreground: #ffffff;
|
||||||
|
--color-success: #41b849;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #ffd452;
|
||||||
|
--color-warning-foreground: #21292c;
|
||||||
|
--color-info: #37beff;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
--color-selection: #374549;
|
||||||
|
--color-selection-foreground: #37beff;
|
||||||
|
--color-unread: #b88a00;
|
||||||
|
--color-chart-1: #37beff;
|
||||||
|
--color-chart-2: #41b849;
|
||||||
|
--color-chart-3: #ffd452;
|
||||||
|
--color-chart-4: #ff5552;
|
||||||
|
--color-chart-5: #b48ead;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Component-level overrides that the colour tokens alone can't express:
|
||||||
|
// Roboto typography and Elastic's signature dark "task menu" rail (which is
|
||||||
|
// dark in both light and dark mode). Scoped under the skin body attribute so
|
||||||
|
// it cleanly detaches when the theme is switched off.
|
||||||
|
const elasticSkin = `
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] {
|
||||||
|
font-family: Roboto, "Helvetica Neue", "Segoe UI", Arial, "Noto Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task menu (left navigation rail) ─────────────────────────── */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary {
|
||||||
|
background-color: #2f3a3f !important;
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.25) !important;
|
||||||
|
}
|
||||||
|
/* In dark mode the black hairline vanishes against the dark canvas - use a
|
||||||
|
light one so the rail still reads as a distinct column. (.dark lives on
|
||||||
|
<html>, so it must be an ancestor of the skinned <body>.) */
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary {
|
||||||
|
border-right-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
/* Light icons + labels on the dark rail */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-muted-foreground {
|
||||||
|
color: #e7edee !important;
|
||||||
|
}
|
||||||
|
/* Hover slab */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a:hover,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button:hover {
|
||||||
|
background-color: #41525a !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
/* Selected task: brighter slab, accent-blue glyph (matches Elastic) */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .bg-primary\\/10 {
|
||||||
|
background-color: #41525a !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-primary {
|
||||||
|
color: #37beff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flatten Bulwark's soft radii to Elastic's Bootstrap-flat look ── */
|
||||||
|
/* Elastic uses ~4px corners on buttons/inputs/cards; rounded-full (pills,
|
||||||
|
avatars, toggles) is intentionally left alone. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-md,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-lg,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-xl,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-2xl,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-3xl,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] input,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] textarea,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] button:not(.rounded-full) {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Unify panel backgrounds like Roundcube ───────────────────── */
|
||||||
|
/* In Elastic the folder list, message list and content pane all share one
|
||||||
|
canvas (white / dark); only the narrow task rail is dark. Bulwark's folder
|
||||||
|
sidebar uses \`bg-secondary\` (a grey panel), so repaint it with the main
|
||||||
|
background. The folder sidebar carries the \`border-r\` class; the dark task
|
||||||
|
rail uses \`bg-secondary\` WITHOUT it (inline-styled border), so this
|
||||||
|
qualifier leaves the rail dark. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r {
|
||||||
|
background-color: var(--color-background) !important;
|
||||||
|
}
|
||||||
|
/* The empty "No conversation selected" pane uses a muted diagonal gradient;
|
||||||
|
flatten it to the plain canvas so the content area is one solid colour. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
|
||||||
|
background: var(--color-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The message-viewer body sits on a faint muted backing (bg-muted/30); flatten
|
||||||
|
it to the plain canvas so the whole content pane - search bar, action
|
||||||
|
toolbar, mail header and body - is one uniform background colour. The
|
||||||
|
search/toolbar/header are bordered \`bg-background\` strips, so they already
|
||||||
|
match once we leave them untinted. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-muted\\/30 {
|
||||||
|
background-color: var(--color-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Elastic primary buttons (.btn-primary) ──────────────────── */
|
||||||
|
/* Solid accent fill, white label + icon, hairline border, focus halo. Light
|
||||||
|
= @color-main (#37beff); dark = @color-dark-main (darken 30% -> #006a9d)
|
||||||
|
with the bright #37beff outline, exactly like the on-switch. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground {
|
||||||
|
background-color: #37beff !important;
|
||||||
|
border: 1px solid #37beff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover {
|
||||||
|
background-color: #19b6fe !important;
|
||||||
|
border-color: #0bb0ff !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:focus-visible {
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(55, 190, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground {
|
||||||
|
background-color: #006a9d !important;
|
||||||
|
border-color: #37beff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover {
|
||||||
|
background-color: #0079b3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Elastic on/off switch (.custom-switch) ──────────────────── */
|
||||||
|
/* Track + knob restyled to the Bootstrap-derived Elastic switch. The toggle
|
||||||
|
is a [role="switch"] button with a single inner <span> knob. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] {
|
||||||
|
background-color: #ced4da !important;
|
||||||
|
border: 1px solid #b8c0c8 !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] {
|
||||||
|
background-color: #37beff !important;
|
||||||
|
border-color: #37beff !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] {
|
||||||
|
background-color: #4d6066 !important;
|
||||||
|
border-color: #5d7077 !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span {
|
||||||
|
background-color: #c5d1d3 !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] {
|
||||||
|
background-color: #006a9d !important;
|
||||||
|
border-color: #37beff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Recipient name/email rendered as links (like Roundcube) ──── */
|
||||||
|
/* Sender + recipient triggers (RecipientPopover) sit at \`text-foreground\`
|
||||||
|
and only turn blue on hover; Elastic shows them link-blue at rest
|
||||||
|
(@color-link #00acff, brighter in dark). The three-class combo is unique
|
||||||
|
to these recipient buttons. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer {
|
||||||
|
color: #00acff !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer {
|
||||||
|
color: #37beff !important;
|
||||||
|
}
|
||||||
|
/* The sender's email address printed under the name is muted grey; Roundcube
|
||||||
|
shows it link-blue too. It's the div immediately after the name row
|
||||||
|
(.flex.items-center.flex-wrap), which the contact-card org line is not, so
|
||||||
|
this sibling selector scopes it to the sender email only. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate {
|
||||||
|
color: #00acff !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate {
|
||||||
|
color: #37beff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Elastic context / popup menus ───────────────────────────── */
|
||||||
|
/* Roundcube highlights the hovered menu entry with a solid accent fill and
|
||||||
|
white text (@color-menu-hover-background: @color-main, @color-menu-hover:
|
||||||
|
#fff) - the same in light and dark. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus-visible {
|
||||||
|
background-color: #37beff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
/* Icons (currentColor) and muted accessories (shortcuts, submenu chevron)
|
||||||
|
follow the white text on hover. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover svg,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus svg,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover .text-muted-foreground {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Elastic folder list: unread count as a rounded pill ─────── */
|
||||||
|
/* Roundcube shows the unread count in a small grey pill on the right, and no
|
||||||
|
badge at all when a folder has nothing unread. Bulwark renders plain
|
||||||
|
"unread / total" text; the counts container is
|
||||||
|
span.ml-2.flex-shrink-0.gap-1.items-baseline holding an unread span
|
||||||
|
(.font-semibold), an optional "/" (.text-muted-foreground/60) and the total
|
||||||
|
(.text-muted-foreground). Reshape it into a pill, drop the total + slash,
|
||||||
|
and hide the whole badge when there's no unread span. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline {
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.6rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background-color: #e4e8ea;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground,
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground\\/60 {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span {
|
||||||
|
color: #5e6b70 !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
/* No unread span -> no badge (matches Sent/Drafts in Roundcube). */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline:not(:has(span.font-semibold)) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline {
|
||||||
|
background-color: #3f4e55;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span {
|
||||||
|
color: #c9d3d5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A slightly bolder accent bar on the selected folder, like Elastic. */
|
||||||
|
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r .border-l-2.border-primary {
|
||||||
|
border-left-width: 3px !important;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// "Aurora Glass" - an ultra-modern glassmorphism skin. Vibrant indigo→cyan
|
||||||
|
// accents float over a fixed gradient-mesh canvas; every structural surface
|
||||||
|
// (nav rail, folder sidebar, cards, popovers, menus, dialogs, toolbars) is a
|
||||||
|
// translucent frosted pane with heavy backdrop-blur + saturation, hairline
|
||||||
|
// luminous borders and soft glow on the primary action.
|
||||||
|
//
|
||||||
|
// The colour tokens stay near-solid for legible text contrast; the frosted
|
||||||
|
// translucency + blur all live in the skin, where a single selector can pair
|
||||||
|
// an rgba background with the matching backdrop-filter.
|
||||||
|
const auroraCSS = `
|
||||||
|
:root {
|
||||||
|
--color-border: rgba(13, 18, 45, 0.10);
|
||||||
|
--color-input: rgba(13, 18, 45, 0.12);
|
||||||
|
--color-ring: #6d5cff;
|
||||||
|
--color-background: #eef1fb;
|
||||||
|
--color-foreground: #14162e;
|
||||||
|
--color-primary: #6d5cff;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #e3e7f7;
|
||||||
|
--color-secondary-foreground: #14162e;
|
||||||
|
--color-muted: #e7eaf6;
|
||||||
|
--color-muted-foreground: #5b6080;
|
||||||
|
--color-accent: #e0e7ff;
|
||||||
|
--color-accent-foreground: #4338ca;
|
||||||
|
--color-destructive: #f43f5e;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #14162e;
|
||||||
|
--color-sidebar: #eef1fb;
|
||||||
|
--color-sidebar-foreground: #14162e;
|
||||||
|
--color-sidebar-border: rgba(13, 18, 45, 0.08);
|
||||||
|
--color-sidebar-accent: rgba(109, 92, 255, 0.10);
|
||||||
|
--color-sidebar-accent-foreground: #4338ca;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #14162e;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-warning-foreground: #14162e;
|
||||||
|
--color-info: #06b6d4;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
--color-selection: rgba(109, 92, 255, 0.14);
|
||||||
|
--color-selection-foreground: #4338ca;
|
||||||
|
--color-unread: #6d5cff;
|
||||||
|
--color-chart-1: #6d5cff;
|
||||||
|
--color-chart-2: #06b6d4;
|
||||||
|
--color-chart-3: #ec4899;
|
||||||
|
--color-chart-4: #f59e0b;
|
||||||
|
--color-chart-5: #10b981;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--color-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--color-input: rgba(255, 255, 255, 0.10);
|
||||||
|
--color-ring: #8b7bff;
|
||||||
|
--color-background: #06070f;
|
||||||
|
--color-foreground: #eef0ff;
|
||||||
|
--color-primary: #8b7bff;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: rgba(255, 255, 255, 0.06);
|
||||||
|
--color-secondary-foreground: #eef0ff;
|
||||||
|
--color-muted: rgba(255, 255, 255, 0.05);
|
||||||
|
--color-muted-foreground: #9aa0c4;
|
||||||
|
--color-accent: rgba(139, 123, 255, 0.16);
|
||||||
|
--color-accent-foreground: #c4bbff;
|
||||||
|
--color-destructive: #fb7185;
|
||||||
|
--color-destructive-foreground: #1a0b10;
|
||||||
|
--color-popover: #12132a;
|
||||||
|
--color-popover-foreground: #eef0ff;
|
||||||
|
--color-sidebar: #08091a;
|
||||||
|
--color-sidebar-foreground: #eef0ff;
|
||||||
|
--color-sidebar-border: rgba(255, 255, 255, 0.07);
|
||||||
|
--color-sidebar-accent: rgba(139, 123, 255, 0.18);
|
||||||
|
--color-sidebar-accent-foreground: #c4bbff;
|
||||||
|
--color-card: rgba(255, 255, 255, 0.04);
|
||||||
|
--color-card-foreground: #eef0ff;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-success-foreground: #052e1f;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-warning-foreground: #1a1405;
|
||||||
|
--color-info: #22d3ee;
|
||||||
|
--color-info-foreground: #04222a;
|
||||||
|
--color-selection: rgba(139, 123, 255, 0.22);
|
||||||
|
--color-selection-foreground: #c4bbff;
|
||||||
|
--color-unread: #8b7bff;
|
||||||
|
--color-chart-1: #8b7bff;
|
||||||
|
--color-chart-2: #22d3ee;
|
||||||
|
--color-chart-3: #f472b6;
|
||||||
|
--color-chart-4: #fbbf24;
|
||||||
|
--color-chart-5: #34d399;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Glassmorphism skin: the frosted translucency, backdrop-blur, gradient-mesh
|
||||||
|
// canvas, luminous borders and glow that the colour tokens alone can't carry.
|
||||||
|
// Scoped under the skin body attribute so it detaches cleanly on switch-off.
|
||||||
|
const auroraSkin = `
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] {
|
||||||
|
font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient-mesh canvas ──────────────────────────────────────── */
|
||||||
|
/* A multi-stop radial mesh painted onto the app-shell root (the unique
|
||||||
|
.bg-background.overflow-hidden container) so the frosted panes have
|
||||||
|
something luminous to blur. Painting it directly on the shell - rather than
|
||||||
|
a fixed body::before with z-indexed children - avoids creating stacking
|
||||||
|
contexts on portaled popups/dialogs (Radix portals render as direct <body>
|
||||||
|
children), which would otherwise break their layering. */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.22), transparent 60%),
|
||||||
|
radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.18), transparent 55%),
|
||||||
|
radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.16), transparent 60%),
|
||||||
|
radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.12), transparent 55%);
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.16), transparent 58%),
|
||||||
|
radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.11), transparent 52%),
|
||||||
|
radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.10), transparent 58%),
|
||||||
|
radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.07), transparent 52%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pill-soft radii everywhere ───────────────────────────────── */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .rounded-md,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .rounded-lg {
|
||||||
|
border-radius: 0.85rem !important;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .rounded-xl,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .rounded-2xl {
|
||||||
|
border-radius: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Frosted panes: the shared glass recipe ───────────────────── */
|
||||||
|
/* Nav rail, folder sidebar, content backing, cards, popovers, dialogs and
|
||||||
|
menus all share one frosted treatment - translucent fill + heavy backdrop
|
||||||
|
blur + saturation so the mesh and neighbours bleed through softly. */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r {
|
||||||
|
background-color: rgba(255, 255, 255, 0.55) !important;
|
||||||
|
backdrop-filter: blur(26px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(26px) saturate(180%);
|
||||||
|
border-color: rgba(13, 18, 45, 0.08) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary,
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r {
|
||||||
|
background-color: rgba(8, 9, 22, 0.82) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.07) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "no conversation selected" empty pane sits inside an opaque
|
||||||
|
bg-background column, so it can't inherit the shell mesh - paint the same
|
||||||
|
gradient mesh directly onto it so the placeholder reads as glass too. */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
|
||||||
|
background-color: var(--color-background) !important;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.22), transparent 60%),
|
||||||
|
radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.18), transparent 55%),
|
||||||
|
radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.16), transparent 60%) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.18), transparent 58%),
|
||||||
|
radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.12), transparent 52%),
|
||||||
|
radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.11), transparent 58%) !important;
|
||||||
|
}
|
||||||
|
/* The reading-pane message backing goes clear glass so the frosted panes and
|
||||||
|
gradient bleed through softly. */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-muted\\/30 {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards, popovers and dialogs: brighter frosted glass with a luminous edge. */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-card,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-popover,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="menu"],
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="dialog"],
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] {
|
||||||
|
background-color: rgba(255, 255, 255, 0.72) !important;
|
||||||
|
backdrop-filter: blur(28px) saturate(190%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(190%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
||||||
|
box-shadow: 0 12px 40px -12px rgba(20, 22, 46, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-card,
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-popover,
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"],
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="dialog"],
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] {
|
||||||
|
background-color: rgba(16, 17, 38, 0.86) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10) !important;
|
||||||
|
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Primary action: gradient fill + glow ─────────────────────── */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground {
|
||||||
|
background-image: linear-gradient(135deg, #7c5cff 0%, #6d5cff 45%, #4f9bff 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 6px 20px -4px rgba(109, 92, 255, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.35) !important;
|
||||||
|
transition: box-shadow 160ms ease, filter 160ms ease;
|
||||||
|
}
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
box-shadow: 0 10px 30px -4px rgba(109, 92, 255, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.4) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground {
|
||||||
|
background-image: linear-gradient(135deg, #8b7bff 0%, #6d5cff 50%, #22d3ee 130%) !important;
|
||||||
|
box-shadow: 0 6px 24px -4px rgba(139, 123, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selected folder / row: soft tinted glass + accent bar ────── */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r .border-l-2.border-primary {
|
||||||
|
border-left-width: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── On/off switch: gradient when active ──────────────────────── */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] {
|
||||||
|
background-image: linear-gradient(135deg, #7c5cff, #4f9bff) !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: 0 2px 10px -1px rgba(109, 92, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] {
|
||||||
|
background-image: linear-gradient(135deg, #8b7bff, #22d3ee) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Menu hover: tinted accent slab (keeps glass legible) ─────── */
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus,
|
||||||
|
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible {
|
||||||
|
background-color: rgba(109, 92, 255, 0.14) !important;
|
||||||
|
color: var(--color-foreground) !important;
|
||||||
|
}
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover,
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus,
|
||||||
|
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible {
|
||||||
|
background-color: rgba(139, 123, 255, 0.22) !important;
|
||||||
|
}`;
|
||||||
|
|
||||||
export const BUILTIN_THEMES: InstalledTheme[] = [
|
export const BUILTIN_THEMES: InstalledTheme[] = [
|
||||||
{
|
{
|
||||||
id: 'builtin-qui',
|
id: 'builtin-qui',
|
||||||
@@ -373,4 +919,30 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
builtIn: true,
|
builtIn: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'builtin-roundcube-elastic',
|
||||||
|
name: 'Roundcube Elastic',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Built-in',
|
||||||
|
description: 'Faithful recreation of Roundcube\'s Elastic skin - Roboto, the #37beff blue, and the dark task-menu rail',
|
||||||
|
css: elasticCSS,
|
||||||
|
skin: elasticSkin,
|
||||||
|
variants: ['light', 'dark'],
|
||||||
|
typography: { fontSans: 'Roboto, "Helvetica Neue", Arial, sans-serif', baseFontSize: '14px' },
|
||||||
|
enabled: true,
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'builtin-aurora-glass',
|
||||||
|
name: 'Aurora Glass',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Built-in',
|
||||||
|
description: 'Ultra-modern glassmorphism - frosted blurred panes floating over a vibrant indigo→cyan gradient mesh, with glowing gradient actions',
|
||||||
|
css: auroraCSS,
|
||||||
|
skin: auroraSkin,
|
||||||
|
variants: ['light', 'dark'],
|
||||||
|
typography: { fontSans: '"Inter", "SF Pro Display", "Segoe UI", system-ui, sans-serif' },
|
||||||
|
enabled: true,
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+123
-9
@@ -1,5 +1,5 @@
|
|||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
|
||||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
import { getDemoData, type DemoData } from './demo-data';
|
import { getDemoData, type DemoData } from './demo-data';
|
||||||
import { generateDemoId } from './demo-utils';
|
import { generateDemoId } from './demo-utils';
|
||||||
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
|
|||||||
export class DemoJMAPClient implements IJMAPClient {
|
export class DemoJMAPClient implements IJMAPClient {
|
||||||
private data: DemoData;
|
private data: DemoData;
|
||||||
private blobStore = new Map<string, Blob>();
|
private blobStore = new Map<string, Blob>();
|
||||||
|
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
|
||||||
private connectionCallback: ((connected: boolean) => void) | null = null;
|
private connectionCallback: ((connected: boolean) => void) | null = null;
|
||||||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||||
private lastStates: AccountStates = {};
|
private lastStates: AccountStates = {};
|
||||||
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// ── Capabilities ──────────────────────────────────────────────
|
// ── Capabilities ──────────────────────────────────────────────
|
||||||
|
|
||||||
hasAccountCapability(_capability: string, _accountId?: string): boolean {
|
hasAccountCapability(capability: string, _accountId?: string): boolean {
|
||||||
return false;
|
return capability === 'urn:ietf:params:jmap:submission';
|
||||||
}
|
}
|
||||||
|
|
||||||
getCapabilities(): Record<string, unknown> {
|
getCapabilities(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||||
'urn:ietf:params:jmap:mail': {},
|
'urn:ietf:params:jmap:mail': {},
|
||||||
'urn:ietf:params:jmap:submission': {},
|
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
|
||||||
'urn:ietf:params:jmap:vacationresponse': {},
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
'urn:ietf:params:jmap:contacts': {},
|
'urn:ietf:params:jmap:contacts': {},
|
||||||
'urn:ietf:params:jmap:calendars': {},
|
'urn:ietf:params:jmap:calendars': {},
|
||||||
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getMaxSizeUpload(): number { return 50_000_000; }
|
getMaxSizeUpload(): number { return 50_000_000; }
|
||||||
getMaxCallsInRequest(): number { return 16; }
|
getMaxCallsInRequest(): number { return 16; }
|
||||||
getMaxObjectsInGet(): number { return 500; }
|
getMaxObjectsInGet(): number { return 500; }
|
||||||
|
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||||
|
hasDelayedSend(): boolean { return true; }
|
||||||
getEventSourceUrl(): string | null { return null; }
|
getEventSourceUrl(): string | null { return null; }
|
||||||
supportsEmailSubmission(): boolean { return true; }
|
supportsEmailSubmission(): boolean { return true; }
|
||||||
supportsQuota(): boolean { return true; }
|
supportsQuota(): boolean { return true; }
|
||||||
@@ -293,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
emails: Array<{ id: string; receivedAt: string }>,
|
emails: Array<{ id: string; receivedAt: string }>,
|
||||||
archiveMailboxId: string,
|
archiveMailboxId: string,
|
||||||
mode: 'single' | 'year' | 'month',
|
mode: 'single' | 'year' | 'month',
|
||||||
|
_existingMailboxes: Mailbox[],
|
||||||
|
_accountId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (emails.length === 0) return;
|
if (emails.length === 0) return;
|
||||||
if (mode === 'single') {
|
if (mode === 'single') {
|
||||||
@@ -324,7 +329,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
this.recalcMailboxCounts();
|
this.recalcMailboxCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async emptyMailbox(mailboxId: string): Promise<number> {
|
async emptyMailbox(mailboxId: string, _accountId?: string): Promise<number> {
|
||||||
const before = this.data.emails.length;
|
const before = this.data.emails.length;
|
||||||
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||||
const removed = before - this.data.emails.length;
|
const removed = before - this.data.emails.length;
|
||||||
@@ -456,7 +461,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
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[],
|
||||||
): Promise<void> {
|
delayedUntil?: string,
|
||||||
|
_envelopeMailFrom?: string,
|
||||||
|
): Promise<SendEmailResult> {
|
||||||
// Remove draft if updating
|
// Remove draft if updating
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||||
@@ -464,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||||
const email: Email = {
|
const email: Email = {
|
||||||
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||||
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
|
||||||
keywords: { $seen: true },
|
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||||
size: body.length + (htmlBody?.length || 0),
|
size: body.length + (htmlBody?.length || 0),
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
@@ -485,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
references: references?.length ? references : undefined,
|
references: references?.length ? references : undefined,
|
||||||
};
|
};
|
||||||
this.data.emails.push(email);
|
this.data.emails.push(email);
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
if (delayedUntil) {
|
||||||
|
emailSubmissionId = generateDemoId('submission');
|
||||||
|
this.scheduledSubmissions.set(emailSubmissionId, {
|
||||||
|
id: emailSubmissionId,
|
||||||
|
emailId: email.id,
|
||||||
|
identityId: _identityId || 'demo-identity',
|
||||||
|
sendAt: delayedUntil,
|
||||||
|
undoStatus: 'pending',
|
||||||
|
isSmime: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.recalcMailboxCounts();
|
this.recalcMailboxCounts();
|
||||||
|
return delayedUntil
|
||||||
|
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
|
||||||
|
: { scheduled: false, emailId: email.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||||
@@ -508,6 +530,14 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return { blobId, size: file.size, type: file.type };
|
return { blobId, size: file.size, type: file.type };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importEmail(): Promise<string | null> {
|
||||||
|
return generateDemoId('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendReadReceipt(): Promise<void> {
|
||||||
|
// Demo mode: no real network send.
|
||||||
|
}
|
||||||
|
|
||||||
getBlobDownloadUrl(blobId: string): string {
|
getBlobDownloadUrl(blobId: string): string {
|
||||||
return `data:application/octet-stream;demo-blob=${blobId}`;
|
return `data:application/octet-stream;demo-blob=${blobId}`;
|
||||||
}
|
}
|
||||||
@@ -872,6 +902,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAllFileNodes(): Promise<FileNode[]> {
|
||||||
|
return [...this.data.fileNodes];
|
||||||
|
}
|
||||||
|
|
||||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||||
if (ids === null) return [...this.data.fileNodes];
|
if (ids === null) return [...this.data.fileNodes];
|
||||||
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||||
@@ -902,6 +936,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||||
|
const updated: string[] = [];
|
||||||
|
for (const [id, patch] of Object.entries(updates)) {
|
||||||
|
const node = this.data.fileNodes.find(n => n.id === id);
|
||||||
|
if (node) {
|
||||||
|
Object.assign(node, patch, { updated: new Date().toISOString() });
|
||||||
|
updated.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { updated, notUpdated: {} };
|
||||||
|
}
|
||||||
|
|
||||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
||||||
@@ -918,7 +964,75 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
async submitEmail(): Promise<void> { /* no-op */ }
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
async sendRawEmail(): Promise<void> { /* no-op */ }
|
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
|
||||||
|
const emailId = generateDemoId('email');
|
||||||
|
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
|
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
|
||||||
|
const email: Email = {
|
||||||
|
id: emailId,
|
||||||
|
threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
|
||||||
|
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||||
|
size: 1024,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [],
|
||||||
|
subject: 'S/MIME message',
|
||||||
|
preview: 'Signed/encrypted demo message',
|
||||||
|
hasAttachment: false,
|
||||||
|
};
|
||||||
|
this.data.emails.push(email);
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
if (delayedUntil) {
|
||||||
|
emailSubmissionId = generateDemoId('submission');
|
||||||
|
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
||||||
|
const pending = Array.from(this.scheduledSubmissions.values())
|
||||||
|
.filter(s => s.undoStatus === 'pending')
|
||||||
|
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||||
|
const page = pending.slice(position, position + limit);
|
||||||
|
const emails = page.map((submission) => {
|
||||||
|
const email = this.data.emails.find(e => e.id === submission.emailId);
|
||||||
|
if (!email) return null;
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
scheduledSendAt: submission.sendAt,
|
||||||
|
emailSubmissionId: submission.id,
|
||||||
|
scheduledIdentityId: submission.identityId,
|
||||||
|
scheduledUndoStatus: submission.undoStatus,
|
||||||
|
isScheduled: true,
|
||||||
|
isSmimeScheduled: submission.isSmime,
|
||||||
|
} satisfies ScheduledEmail;
|
||||||
|
}).filter((email): email is ScheduledEmail => email !== null);
|
||||||
|
const nextPosition = position + page.length;
|
||||||
|
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
||||||
|
const submission = this.scheduledSubmissions.get(submissionId);
|
||||||
|
if (submission) submission.undoStatus = 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
||||||
|
await this.cancelEmailSubmission(submissionId);
|
||||||
|
const replacement = generateDemoId('submission');
|
||||||
|
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
|
||||||
|
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
email.mailboxIds[draftMailboxId] = true;
|
||||||
|
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
|
||||||
|
email.keywords.$draft = true;
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+66
-6
@@ -1,23 +1,83 @@
|
|||||||
import { AuthenticationResults } from './jmap/types';
|
import { AuthenticationResults } from './jmap/types';
|
||||||
import { parseUnsubscribeUrls } from './validation';
|
import { parseUnsubscribeUrls } from './validation';
|
||||||
|
|
||||||
|
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||||
|
type SpfEntry = NonNullable<NonNullable<AuthenticationResults['spf']>['all']>[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity ranking for SPF results. Higher = more severe / more actionable.
|
||||||
|
* A hard `fail` is a definitive policy violation and must outrank ambiguous
|
||||||
|
* states like `temperror`, so a spoofed message isn't softened to a
|
||||||
|
* "temporary failure" headline when one identity hard-fails.
|
||||||
|
*/
|
||||||
|
const SPF_SEVERITY: Record<SpfResult, number> = {
|
||||||
|
fail: 6,
|
||||||
|
softfail: 5,
|
||||||
|
permerror: 4,
|
||||||
|
temperror: 3,
|
||||||
|
neutral: 2,
|
||||||
|
none: 1,
|
||||||
|
pass: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the authentication results indicate the visible From identity can't
|
||||||
|
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
||||||
|
* would otherwise imply the message legitimately came from one of the user's
|
||||||
|
* own identities (e.g. the "via <identity>" badge).
|
||||||
|
*/
|
||||||
|
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||||
|
if (!auth) return false;
|
||||||
|
// DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the
|
||||||
|
// strongest single spoofing signal.
|
||||||
|
if (auth.dmarc?.result === 'fail') return true;
|
||||||
|
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
|
||||||
|
// isn't authorized for the envelope domain.
|
||||||
|
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
||||||
*/
|
*/
|
||||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||||
const results: AuthenticationResults = {};
|
const results: AuthenticationResults = {};
|
||||||
|
|
||||||
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
|
||||||
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||||
type DmarcResult = 'pass' | 'fail' | 'none';
|
type DmarcResult = 'pass' | 'fail' | 'none';
|
||||||
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
||||||
|
|
||||||
// Parse SPF
|
// Parse SPF. A single Authentication-Results header can carry more than one
|
||||||
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
|
// SPF result when the server evaluates multiple identities (HELO and MAIL
|
||||||
if (spfMatch) {
|
// FROM). Collect them all and surface the most severe as the headline so a
|
||||||
|
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`.
|
||||||
|
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
|
||||||
|
const spfResults: SpfEntry[] = [];
|
||||||
|
let spfM: RegExpExecArray | null;
|
||||||
|
while ((spfM = spfRegex.exec(header)) !== null) {
|
||||||
|
spfResults.push({
|
||||||
|
result: spfM[1] as SpfResult,
|
||||||
|
identity: spfM[2] as SpfEntry['identity'],
|
||||||
|
domain: spfM[3],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (spfResults.length > 0) {
|
||||||
|
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
||||||
|
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful
|
||||||
|
// than HELO) and otherwise keep the first occurrence.
|
||||||
|
const primary = spfResults.reduce((best, cur) => {
|
||||||
|
if (severity(cur.result) > severity(best.result)) return cur;
|
||||||
|
if (
|
||||||
|
severity(cur.result) === severity(best.result) &&
|
||||||
|
best.identity !== 'mailfrom' &&
|
||||||
|
cur.identity === 'mailfrom'
|
||||||
|
) return cur;
|
||||||
|
return best;
|
||||||
|
});
|
||||||
results.spf = {
|
results.spf = {
|
||||||
result: spfMatch[1] as SpfResult,
|
result: primary.result,
|
||||||
domain: spfMatch[2]
|
domain: primary.domain,
|
||||||
|
...(spfResults.length > 1 ? { all: spfResults } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,4 @@
|
|||||||
export type FilePreviewKind = 'image' | 'html' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
export type FilePreviewKind = 'image' | 'html' | 'eml' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
|
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
|
||||||
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
|
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
|
||||||
@@ -38,6 +38,12 @@ export function getFilePreviewKind(name?: string, type?: string): FilePreviewKin
|
|||||||
return 'html';
|
return 'html';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Embedded email message (e.g. a bounce/DSN, or forward-as-attachment).
|
||||||
|
// Rendered by parsing it and showing it like an email, not as a blob.
|
||||||
|
if (mimeType === 'message/rfc822' || ext === 'eml') {
|
||||||
|
return 'eml';
|
||||||
|
}
|
||||||
|
|
||||||
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||||
return 'pdf';
|
return 'pdf';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +31,8 @@ export interface IJMAPClient {
|
|||||||
getMaxSizeUpload(): number;
|
getMaxSizeUpload(): number;
|
||||||
getMaxCallsInRequest(): number;
|
getMaxCallsInRequest(): number;
|
||||||
getMaxObjectsInGet(): number;
|
getMaxObjectsInGet(): number;
|
||||||
|
getMaxDelayedSend(accountId?: string): number;
|
||||||
|
hasDelayedSend(accountId?: string): boolean;
|
||||||
getEventSourceUrl(): string | null;
|
getEventSourceUrl(): string | null;
|
||||||
supportsEmailSubmission(): boolean;
|
supportsEmailSubmission(): boolean;
|
||||||
supportsQuota(): boolean;
|
supportsQuota(): boolean;
|
||||||
@@ -95,7 +97,7 @@ export interface IJMAPClient {
|
|||||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||||
setKeyword(emailId: string, keyword: string): Promise<void>;
|
setKeyword(emailId: string, keyword: string): Promise<void>;
|
||||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||||
deleteEmail(emailId: string): Promise<void>;
|
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||||
@@ -107,7 +109,7 @@ export interface IJMAPClient {
|
|||||||
accountId?: string,
|
accountId?: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
emptyMailbox(mailboxId: string): Promise<number>;
|
emptyMailbox(mailboxId: string, accountId?: string): Promise<number>;
|
||||||
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
||||||
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
||||||
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||||
@@ -146,8 +148,37 @@ export interface IJMAPClient {
|
|||||||
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,
|
||||||
envelopeMailFrom?: string,
|
envelopeMailFrom?: string,
|
||||||
): Promise<void>;
|
options?: { requestReadReceipt?: boolean },
|
||||||
|
): Promise<SendEmailResult>;
|
||||||
|
|
||||||
|
importEmail(
|
||||||
|
blobId: string,
|
||||||
|
mailboxIds: Record<string, boolean>,
|
||||||
|
keywords?: Record<string, boolean>,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<string | null>;
|
||||||
|
|
||||||
|
sendReadReceipt(params: {
|
||||||
|
to: string;
|
||||||
|
fromEmail: string;
|
||||||
|
fromName?: string;
|
||||||
|
identityId: string;
|
||||||
|
originalMessageId?: string | string[];
|
||||||
|
originalSubject?: string;
|
||||||
|
originalRecipient?: string;
|
||||||
|
automatic?: boolean;
|
||||||
|
accountId?: string;
|
||||||
|
subject?: string;
|
||||||
|
humanText?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||||
|
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
||||||
|
cancelEmailSubmission(submissionId: string): Promise<void>;
|
||||||
|
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
|
||||||
|
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
|
||||||
|
|
||||||
sendImipReply(opts: {
|
sendImipReply(opts: {
|
||||||
organizerEmail: string;
|
organizerEmail: string;
|
||||||
@@ -179,8 +210,8 @@ export interface IJMAPClient {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
},
|
},
|
||||||
): Promise<{ blobId: string; size: number; type: string }>;
|
): Promise<{ blobId: string; size: number; type: string }>;
|
||||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
|
||||||
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
|
||||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||||
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
||||||
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
||||||
@@ -275,15 +306,16 @@ export interface IJMAPClient {
|
|||||||
getFilesAccountId(): string;
|
getFilesAccountId(): string;
|
||||||
probeFileNodeSupport(): Promise<boolean>;
|
probeFileNodeSupport(): Promise<boolean>;
|
||||||
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||||
|
listAllFileNodes(): Promise<FileNode[]>;
|
||||||
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||||
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||||
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||||
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
||||||
|
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
|
||||||
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||||
|
|
||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+592
-78
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
|
|||||||
|
|
||||||
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
||||||
|
|
||||||
|
const SUBMISSION_USING = [
|
||||||
|
'urn:ietf:params:jmap:core',
|
||||||
|
'urn:ietf:params:jmap:mail',
|
||||||
|
'urn:ietf:params:jmap:submission',
|
||||||
|
] as const;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type JMAPResponseResult = Record<string, any>;
|
type JMAPResponseResult = Record<string, any>;
|
||||||
|
|
||||||
@@ -312,6 +318,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
|||||||
return emailCount === limit;
|
return emailCount === limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
|
||||||
|
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSmimeEmail(email: Email): boolean {
|
||||||
|
const types: string[] = [];
|
||||||
|
const collect = (part: Email['bodyStructure']): void => {
|
||||||
|
if (!part) return;
|
||||||
|
if (part.type) types.push(part.type.toLowerCase());
|
||||||
|
part.subParts?.forEach(collect);
|
||||||
|
};
|
||||||
|
collect(email.bodyStructure);
|
||||||
|
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
|
||||||
|
return types.some(type =>
|
||||||
|
type.includes('pkcs7') ||
|
||||||
|
type.includes('x-pkcs7') ||
|
||||||
|
type === 'application/pkcs7-mime' ||
|
||||||
|
type === 'application/pkcs7-signature'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fold a single iCalendar content line per RFC 5545 §3.1.
|
* Fold a single iCalendar content line per RFC 5545 §3.1.
|
||||||
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
||||||
@@ -347,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
|||||||
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
|
||||||
|
return (recipients || [])
|
||||||
|
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((email) => ({ email }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array<string | EmailAddress>): Record<string, unknown> | undefined {
|
||||||
|
if (!holdForSeconds) return undefined;
|
||||||
|
const rcptTo = normalizeEnvelopeRecipients(recipients);
|
||||||
|
return {
|
||||||
|
mailFrom: {
|
||||||
|
email: fromEmail,
|
||||||
|
parameters: {
|
||||||
|
HOLDFOR: String(holdForSeconds),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rcptTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmissionCapability = {
|
||||||
|
maxDelayedSend?: number;
|
||||||
|
submissionExtensions?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export class JMAPClient implements IJMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
@@ -703,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
|
||||||
methodCalls,
|
methodCalls,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1080,7 +1134,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
const authResultsHeader = headersRecord['Authentication-Results'];
|
const authResultsHeader = headersRecord['Authentication-Results'];
|
||||||
if (authResultsHeader) {
|
if (authResultsHeader) {
|
||||||
const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader;
|
// Multiple Authentication-Results headers (or multiple SPF identities in
|
||||||
|
// one header) must all be considered so the most severe result wins.
|
||||||
|
const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader;
|
||||||
email.authenticationResults = parseAuthenticationResults(value);
|
email.authenticationResults = parseAuthenticationResults(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,10 +1273,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return allIds.length;
|
return allIds.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEmail(emailId: string): Promise<void> {
|
async deleteEmail(emailId: string, accountId?: string): Promise<void> {
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", {
|
["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: accountId || this.accountId,
|
||||||
destroy: [emailId],
|
destroy: [emailId],
|
||||||
}, "0"],
|
}, "0"],
|
||||||
]);
|
]);
|
||||||
@@ -1389,19 +1445,20 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async emptyMailbox(mailboxId: string): Promise<number> {
|
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
||||||
|
const targetAccountId = accountId || this.accountId;
|
||||||
let totalDestroyed = 0;
|
let totalDestroyed = 0;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
|
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Email/query", {
|
["Email/query", {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
filter: { inMailbox: mailboxId },
|
filter: { inMailbox: mailboxId },
|
||||||
limit: 500,
|
limit: 500,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/set", {
|
["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
|
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||||
}, "1"],
|
}, "1"],
|
||||||
]);
|
]);
|
||||||
@@ -2094,10 +2151,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
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[],
|
||||||
envelopeMailFrom?: string
|
delayedUntil?: string,
|
||||||
): Promise<void> {
|
envelopeMailFrom?: string,
|
||||||
|
options?: { requestReadReceipt?: boolean }
|
||||||
|
): Promise<SendEmailResult> {
|
||||||
|
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||||
const emailId = `send-${Date.now()}`;
|
const emailId = `send-${Date.now()}`;
|
||||||
const mailboxes = await this.getMailboxes();
|
const targetAccountId = (fromEmail && Object.keys(this.accounts).find(id =>
|
||||||
|
this.accounts[id]?.name?.toLowerCase() === fromEmail.toLowerCase()
|
||||||
|
)) || this.accountId;
|
||||||
|
const mboxResp = await this.request([
|
||||||
|
["Mailbox/get", { accountId: targetAccountId }, "0"]
|
||||||
|
]);
|
||||||
|
const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[];
|
||||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
if (!sentMailbox) {
|
if (!sentMailbox) {
|
||||||
throw new Error('No sent mailbox found');
|
throw new Error('No sent mailbox found');
|
||||||
@@ -2111,25 +2177,25 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
let identityReplyTo: EmailAddress[] | undefined;
|
let identityReplyTo: EmailAddress[] | undefined;
|
||||||
{
|
{
|
||||||
const identityResponse = await this.request([
|
const identityResponse = await this.request([
|
||||||
["Identity/get", { accountId: this.accountId }, "0"]
|
["Identity/get", { accountId: targetAccountId }, "0"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!finalIdentityId) {
|
if (!finalIdentityId) {
|
||||||
finalIdentityId = this.accountId;
|
finalIdentityId = targetAccountId;
|
||||||
}
|
}
|
||||||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
|
const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
|
||||||
if (identities.length > 0) {
|
if (identities.length > 0) {
|
||||||
if (!identityId) {
|
let matchingIdentity = identityId
|
||||||
|
? identities.find((id) => id.id === identityId)
|
||||||
|
: undefined;
|
||||||
|
if (!matchingIdentity) {
|
||||||
const target = fromEmail || this.username;
|
const target = fromEmail || this.username;
|
||||||
const matchingIdentity = identities.find((id) => id.email === target)
|
matchingIdentity = identities.find((id) => id.email === target)
|
||||||
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
||||||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
|
||||||
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
|
|
||||||
} else {
|
|
||||||
const matchedIdentity = identities.find((id) => id.id === identityId);
|
|
||||||
identityReplyTo = matchedIdentity?.replyTo;
|
|
||||||
}
|
}
|
||||||
|
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||||||
|
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2157,6 +2223,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
mailboxIds: { [draftsMailbox.id]: true },
|
mailboxIds: { [draftsMailbox.id]: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options?.requestReadReceipt) {
|
||||||
|
// RFC 8098: ask the recipient's client to return a Message Disposition
|
||||||
|
// Notification to our address. JMAP lets us set the raw header on create
|
||||||
|
// via the "header:<Name>:asText" property form.
|
||||||
|
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
|
||||||
|
}
|
||||||
|
|
||||||
if (htmlBody) {
|
if (htmlBody) {
|
||||||
// Send as multipart/alternative with both text and HTML
|
// Send as multipart/alternative with both text and HTML
|
||||||
emailCreate.bodyValues = {
|
emailCreate.bodyValues = {
|
||||||
@@ -2197,12 +2270,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
||||||
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
||||||
// is omitted the server derives mailFrom from the Identity.
|
// is omitted the server derives mailFrom from the Identity.
|
||||||
const submissionCreate = (submissionId: string): Record<string, unknown> => {
|
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
|
||||||
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
||||||
if (envelopeMailFrom) {
|
if (holdForSeconds || envelopeMailFrom) {
|
||||||
|
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((email) => ({ email }));
|
||||||
create.envelope = {
|
create.envelope = {
|
||||||
mailFrom: { email: envelopeMailFrom },
|
mailFrom: {
|
||||||
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
|
email: envelopeMailFrom || fromEmail || this.username,
|
||||||
|
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
|
||||||
|
},
|
||||||
|
rcptTo: envelopeRecipients,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { [submissionId]: create };
|
return { [submissionId]: create };
|
||||||
@@ -2215,28 +2295,32 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
destroy: [draftId],
|
destroy: [draftId],
|
||||||
}, "0"]);
|
}, "0"]);
|
||||||
methodCalls.push(["Email/set", {
|
methodCalls.push(["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "1"]);
|
}, "1"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(targetAccountId),
|
||||||
create: submissionCreate("1"),
|
create: buildSubmissionCreate("1"),
|
||||||
onSuccessUpdateEmail,
|
onSuccessUpdateEmail,
|
||||||
}, "2"]);
|
}, "2"]);
|
||||||
} else {
|
} else {
|
||||||
methodCalls.push(["Email/set", {
|
methodCalls.push(["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"]);
|
}, "0"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(targetAccountId),
|
||||||
create: submissionCreate("1"),
|
create: buildSubmissionCreate("1"),
|
||||||
onSuccessUpdateEmail,
|
onSuccessUpdateEmail,
|
||||||
}, "1"]);
|
}, "1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
|
let createdEmailId: string | undefined;
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
let serverSendAt: string | undefined;
|
||||||
|
|
||||||
if (response.methodResponses) {
|
if (response.methodResponses) {
|
||||||
for (const [methodName, result] of response.methodResponses) {
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
if (methodName.endsWith('/error')) {
|
if (methodName.endsWith('/error')) {
|
||||||
@@ -2268,8 +2352,24 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
|
||||||
|
createdEmailId = result.created[emailId].id;
|
||||||
|
}
|
||||||
|
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
|
||||||
|
emailSubmissionId = result.created['1'].id;
|
||||||
|
serverSendAt = result.created['1'].sendAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||||||
|
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delayedUntil
|
||||||
|
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
|
||||||
|
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2431,7 +2531,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["EmailSubmission/set", {
|
["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||||
onSuccessUpdateEmail: {
|
onSuccessUpdateEmail: {
|
||||||
"#sub-1": {
|
"#sub-1": {
|
||||||
@@ -2609,7 +2709,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["EmailSubmission/set", {
|
["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
onSuccessUpdateEmail: {
|
onSuccessUpdateEmail: {
|
||||||
"#sub-1": {
|
"#sub-1": {
|
||||||
@@ -2761,7 +2861,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["EmailSubmission/set", {
|
["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
onSuccessUpdateEmail: {
|
onSuccessUpdateEmail: {
|
||||||
"#sub-1": {
|
"#sub-1": {
|
||||||
@@ -2918,21 +3018,125 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error('Invalid upload response: blobId not found');
|
throw new Error('Invalid upload response: blobId not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
/**
|
||||||
|
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
|
||||||
|
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
|
||||||
|
* where the exact MIME bytes must be preserved (Email/set can't express a
|
||||||
|
* multipart/report report-type parameter reliably).
|
||||||
|
*/
|
||||||
|
async importEmail(
|
||||||
|
blobId: string,
|
||||||
|
mailboxIds: Record<string, boolean>,
|
||||||
|
keywords?: Record<string, boolean>,
|
||||||
|
accountId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const creationId = `imp-${Date.now()}`;
|
||||||
|
const response = await this.request([
|
||||||
|
["Email/import", {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
emails: {
|
||||||
|
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
|
||||||
|
},
|
||||||
|
}, "0"],
|
||||||
|
]);
|
||||||
|
const res = response.methodResponses?.[0];
|
||||||
|
if (res?.[0] !== "Email/import") {
|
||||||
|
console.error('Email/import: unexpected response', res);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = res[1] as {
|
||||||
|
created?: Record<string, { id: string }>;
|
||||||
|
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||||
|
};
|
||||||
|
const created = payload?.created?.[creationId];
|
||||||
|
if (!created) {
|
||||||
|
const reason = payload?.notCreated?.[creationId];
|
||||||
|
console.error('Email/import failed:', reason || payload);
|
||||||
|
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
|
||||||
|
* to a message that carried a Disposition-Notification-To header. Builds the
|
||||||
|
* multipart/report, uploads it as a blob, imports it into Sent, then submits
|
||||||
|
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
|
||||||
|
* requesting address).
|
||||||
|
*/
|
||||||
|
async sendReadReceipt(params: {
|
||||||
|
to: string;
|
||||||
|
fromEmail: string;
|
||||||
|
fromName?: string;
|
||||||
|
identityId: string;
|
||||||
|
originalMessageId?: string | string[];
|
||||||
|
originalSubject?: string;
|
||||||
|
originalRecipient?: string;
|
||||||
|
automatic?: boolean;
|
||||||
|
accountId?: string;
|
||||||
|
subject?: string;
|
||||||
|
humanText?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const targetAccountId = params.accountId || this.accountId;
|
||||||
|
const { buildMdnMessage } = await import("@/lib/mdn");
|
||||||
|
const raw = buildMdnMessage(params);
|
||||||
|
|
||||||
|
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
|
||||||
|
const { blobId } = await this.uploadBlob(file);
|
||||||
|
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
|
||||||
|
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
|
||||||
|
|
||||||
|
const emailId = await this.importEmail(
|
||||||
|
blobId,
|
||||||
|
{ [targetMailbox.id]: true },
|
||||||
|
{ "$seen": true },
|
||||||
|
targetAccountId
|
||||||
|
);
|
||||||
|
if (!emailId) throw new Error('MDN import failed');
|
||||||
|
|
||||||
|
const subId = `mdnsub-${Date.now()}`;
|
||||||
|
const response = await this.request([
|
||||||
|
["EmailSubmission/set", {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
create: {
|
||||||
|
[subId]: {
|
||||||
|
emailId,
|
||||||
|
identityId: params.identityId,
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { email: params.fromEmail },
|
||||||
|
rcptTo: [{ email: params.to }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, "0"],
|
||||||
|
]);
|
||||||
|
const subRes = response.methodResponses?.[0];
|
||||||
|
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
|
||||||
|
if (notCreated) {
|
||||||
|
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
|
||||||
if (!this.downloadUrl) {
|
if (!this.downloadUrl) {
|
||||||
throw new Error('Download URL not available. Please reconnect.');
|
throw new Error('Download URL not available. Please reconnect.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 6570 level 1 URI template expansion
|
// RFC 6570 level 1 URI template expansion. Blobs are scoped per account,
|
||||||
|
// so a caller fetching a blob from a delegated/shared account must pass
|
||||||
|
// that owner's accountId rather than defaulting to the primary one.
|
||||||
return this.downloadUrl
|
return this.downloadUrl
|
||||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
.replace('{accountId}', encodeURIComponent(accountId || this.accountId))
|
||||||
.replace('{blobId}', encodeURIComponent(blobId))
|
.replace('{blobId}', encodeURIComponent(blobId))
|
||||||
.replace('{name}', encodeURIComponent(name || 'download'))
|
.replace('{name}', encodeURIComponent(name || 'download'))
|
||||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> {
|
async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob> {
|
||||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||||
const response = await this.authenticatedFetch(url, {});
|
const response = await this.authenticatedFetch(url, {});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
@@ -2975,6 +3179,85 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return coreCapability?.maxObjectsInGet || 500;
|
return coreCapability?.maxObjectsInGet || 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMaxDelayedSend(accountId?: string): number {
|
||||||
|
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
||||||
|
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDelayedSend(accountId?: string): boolean {
|
||||||
|
const submissionCapability = this.getSubmissionCapability(accountId);
|
||||||
|
const submissionExtensions = submissionCapability?.submissionExtensions;
|
||||||
|
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
|
||||||
|
|
||||||
|
return !!submissionCapability
|
||||||
|
&& hasFutureRelease
|
||||||
|
&& this.getMaxDelayedSend(accountId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
|
||||||
|
const time = new Date(delayedUntil).getTime();
|
||||||
|
if (!Number.isFinite(time)) {
|
||||||
|
throw new Error('Scheduled send time is invalid');
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (time <= now) {
|
||||||
|
throw new Error('Scheduled send time must be in the future');
|
||||||
|
}
|
||||||
|
const maxDelayedSend = this.getMaxDelayedSend(accountId);
|
||||||
|
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
|
||||||
|
throw new Error('Scheduled send is not supported for this account');
|
||||||
|
}
|
||||||
|
if (time > now + maxDelayedSend * 1000) {
|
||||||
|
throw new Error('Scheduled send time is later than the server allows');
|
||||||
|
}
|
||||||
|
return Math.ceil((time - now) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
ids: [submissionId],
|
||||||
|
properties: ['sendAt', 'undoStatus'],
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
|
||||||
|
return submission?.sendAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
ids: [submissionId],
|
||||||
|
properties: ['envelope'],
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
|
||||||
|
return submission?.envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubmissionAccountId(accountId?: string): string {
|
||||||
|
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
|
||||||
|
const submissionAccountId = this.getSubmissionAccountId(accountId);
|
||||||
|
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
|
||||||
|
const target = extension.toUpperCase();
|
||||||
|
if (Array.isArray(submissionExtensions)) {
|
||||||
|
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
|
||||||
|
}
|
||||||
|
if (submissionExtensions && typeof submissionExtensions === 'object') {
|
||||||
|
return Object.entries(submissionExtensions as Record<string, unknown>)
|
||||||
|
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
getEventSourceUrl(): string | null {
|
getEventSourceUrl(): string | null {
|
||||||
if (!this.session) return null;
|
if (!this.session) return null;
|
||||||
|
|
||||||
@@ -4743,54 +5026,46 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||||||
|
// FileNode/query omits folder nodes in Stalwart (see listAllFileNodes), so
|
||||||
|
// we enumerate the whole account and filter children client-side.
|
||||||
|
const all = await this.listAllFileNodes();
|
||||||
|
return all.filter(n => (n.parentId ?? null) === parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch every FileNode in the account, files AND folders, to build the folder
|
||||||
|
* hierarchy client-side from parentId links.
|
||||||
|
*
|
||||||
|
* IMPORTANT: this uses `FileNode/get` with `ids: null` (return-all), NOT
|
||||||
|
* `FileNode/query`. Stalwart's FileNode/query only returns leaf files - it
|
||||||
|
* omits container (folder) nodes entirely - so a query-based listing made
|
||||||
|
* every folder invisible (the whole account looked like a single root file).
|
||||||
|
*/
|
||||||
|
async listAllFileNodes(): Promise<FileNode[]> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
const filter: Record<string, unknown> = {};
|
|
||||||
if (parentId !== null) {
|
|
||||||
filter.parentId = parentId;
|
|
||||||
}
|
|
||||||
// When parentId is null (root level), use empty filter to get all nodes.
|
|
||||||
// Stalwart's FileNode/query does not support parentId: null as a filter value.
|
|
||||||
|
|
||||||
const response = await this.request(
|
const response = await this.request(
|
||||||
[
|
[["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]],
|
||||||
["FileNode/query", { accountId, filter }, "fnq0"],
|
|
||||||
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
|
|
||||||
],
|
|
||||||
this.fileUsing(),
|
this.fileUsing(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if query failed first
|
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0"));
|
||||||
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0"));
|
if (!getResult || getResult[0] === "error") {
|
||||||
if (queryResult && queryResult[0] === "error") {
|
console.error('[Files] FileNode/get error:', getResult?.[1]);
|
||||||
console.error('[Files] FileNode/query error:', queryResult[1]);
|
throw new Error(getResult?.[1]?.description || "FileNode list failed");
|
||||||
throw new Error(queryResult[1]?.description || "FileNode/query failed");
|
|
||||||
}
|
}
|
||||||
|
return (getResult[1].list || []) as FileNode[];
|
||||||
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fnq0"));
|
|
||||||
if (!getResult) {
|
|
||||||
console.error('[Files] No FileNode/get response. Full response:', JSON.stringify(response.methodResponses));
|
|
||||||
throw new Error("FileNode list failed - no response");
|
|
||||||
}
|
|
||||||
if (getResult[0] === "error") {
|
|
||||||
console.error('[Files] FileNode/get error:', getResult[1]);
|
|
||||||
throw new Error(getResult[1]?.description || "FileNode list failed");
|
|
||||||
}
|
|
||||||
const nodes = (getResult[1].list || []) as FileNode[];
|
|
||||||
// When listing root, filter client-side to only show root-level items
|
|
||||||
if (parentId === null) {
|
|
||||||
return nodes.filter(n => n.parentId === null);
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
// Stalwart requires a blobId even for directories - upload an empty blob
|
// A FileNode is a folder (container) only when it has no content - i.e. no
|
||||||
const emptyBlob = new File([], name, { type: 'application/x-directory' });
|
// blobId, type or size, so the server stores it with `file == null`. Sending
|
||||||
const { blobId } = await this.uploadBlob(emptyBlob);
|
// any of those - as older builds did (type "d" + an empty blob) - makes it a
|
||||||
|
// 0-byte FILE that nothing can ever be parented under, which is what caused
|
||||||
const dirProps: Record<string, unknown> = { name, type: "d", blobId, size: 0 };
|
// "Parent ID does not exist or is not a folder" during the #379 migration.
|
||||||
|
const dirProps: Record<string, unknown> = { name };
|
||||||
if (parentId !== null) {
|
if (parentId !== null) {
|
||||||
dirProps.parentId = parentId;
|
dirProps.parentId = parentId;
|
||||||
}
|
}
|
||||||
@@ -4869,6 +5144,39 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update many FileNodes in a single FileNode/set call. Returns the ids that
|
||||||
|
* were updated and a map of id -> error for any the server rejected. Never
|
||||||
|
* throws for per-node failures (only for a whole-method error).
|
||||||
|
*/
|
||||||
|
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||||
|
const ids = Object.keys(updates);
|
||||||
|
if (ids.length === 0) return { updated: [], notUpdated: {} };
|
||||||
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
|
const response = await this.request(
|
||||||
|
[["FileNode/set", { accountId, update: updates }, "fns0"]],
|
||||||
|
this.fileUsing(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0];
|
||||||
|
if (!result || result[0] === "error") {
|
||||||
|
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||||||
|
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||||||
|
const notUpdated: Record<string, string> = {};
|
||||||
|
for (const id of Object.keys(notUpdatedMap)) {
|
||||||
|
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||||||
|
}
|
||||||
|
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||||||
|
const updated = Object.keys(updatedMap).length > 0
|
||||||
|
? Object.keys(updatedMap)
|
||||||
|
: ids.filter(id => !(id in notUpdated));
|
||||||
|
return { updated, notUpdated };
|
||||||
|
}
|
||||||
|
|
||||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
@@ -5430,7 +5738,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
['EmailSubmission/set', {
|
['EmailSubmission/set', {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { 'smime-submit': { emailId, identityId } },
|
create: { 'smime-submit': { emailId, identityId } },
|
||||||
}, '0'],
|
}, '0'],
|
||||||
]);
|
]);
|
||||||
@@ -5451,7 +5759,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
identityId: string,
|
identityId: string,
|
||||||
sentMailboxId: string,
|
sentMailboxId: string,
|
||||||
draftMailboxId?: string,
|
draftMailboxId?: string,
|
||||||
): Promise<void> {
|
delayedUntil?: string,
|
||||||
|
envelopeRecipients?: string[],
|
||||||
|
): Promise<SendEmailResult> {
|
||||||
|
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||||
// Upload the raw message
|
// Upload the raw message
|
||||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||||
const { blobId } = await this.uploadBlob(file);
|
const { blobId } = await this.uploadBlob(file);
|
||||||
@@ -5459,6 +5770,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// Import into Drafts first, then move to Sent after submission succeeds.
|
// Import into Drafts first, then move to Sent after submission succeeds.
|
||||||
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
||||||
const importMailboxId = draftMailboxId || sentMailboxId;
|
const importMailboxId = draftMailboxId || sentMailboxId;
|
||||||
|
const identities = await this.getIdentities();
|
||||||
|
const identity = identities.find(item => item.id === identityId);
|
||||||
|
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||||||
|
|
||||||
const methodCalls: [string, Record<string, unknown>, string][] = [
|
const methodCalls: [string, Record<string, unknown>, string][] = [
|
||||||
['Email/import', {
|
['Email/import', {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
@@ -5471,11 +5786,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
},
|
},
|
||||||
}, '0'],
|
}, '0'],
|
||||||
['EmailSubmission/set', {
|
['EmailSubmission/set', {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: {
|
create: {
|
||||||
'raw-submit': {
|
'raw-submit': {
|
||||||
emailId: '#raw-import',
|
emailId: '#raw-import',
|
||||||
identityId,
|
identityId,
|
||||||
|
...(envelope ? { envelope } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(draftMailboxId ? {
|
...(draftMailboxId ? {
|
||||||
@@ -5491,6 +5807,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
let emailId: string | undefined;
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
let serverSendAt: string | undefined;
|
||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
for (const [methodName, result] of response.methodResponses ?? []) {
|
for (const [methodName, result] of response.methodResponses ?? []) {
|
||||||
@@ -5502,6 +5821,201 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const firstErr = Object.values(r.notCreated)[0];
|
const firstErr = Object.values(r.notCreated)[0];
|
||||||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
||||||
}
|
}
|
||||||
|
if (methodName === 'Email/import') {
|
||||||
|
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
|
||||||
|
}
|
||||||
|
if (methodName === 'EmailSubmission/set') {
|
||||||
|
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
|
||||||
|
emailSubmissionId = created?.id;
|
||||||
|
serverSendAt = created?.sendAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||||||
|
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delayedUntil
|
||||||
|
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
|
||||||
|
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
||||||
|
if (!this.hasDelayedSend()) {
|
||||||
|
return { emails: [], hasMore: false, total: 0, nextPosition: position };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const pageSize = Math.max(limit, 50);
|
||||||
|
const submissions: EmailSubmission[] = [];
|
||||||
|
let rawPosition = 0;
|
||||||
|
let rawTotal = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const queryResponse = await this.request([
|
||||||
|
['EmailSubmission/query', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
limit: pageSize,
|
||||||
|
position: rawPosition,
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
|
||||||
|
const ids = query?.ids ?? [];
|
||||||
|
rawTotal = query?.total ?? rawPosition + ids.length;
|
||||||
|
if (ids.length === 0) break;
|
||||||
|
|
||||||
|
const submissionResponse = await this.request([
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
ids,
|
||||||
|
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
|
||||||
|
.filter(submission => {
|
||||||
|
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
|
||||||
|
const sendAtTime = new Date(submission.sendAt).getTime();
|
||||||
|
return Number.isFinite(sendAtTime) && sendAtTime > now;
|
||||||
|
}));
|
||||||
|
|
||||||
|
rawPosition += ids.length;
|
||||||
|
} while (rawPosition < rawTotal);
|
||||||
|
|
||||||
|
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
|
||||||
|
const total = submissions.length;
|
||||||
|
const pageSubmissions = submissions.slice(position, position + limit);
|
||||||
|
const nextPosition = position + pageSubmissions.length;
|
||||||
|
|
||||||
|
if (pageSubmissions.length === 0) {
|
||||||
|
return { emails: [], hasMore: false, total, nextPosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailResponse = await this.request([
|
||||||
|
['Email/get', {
|
||||||
|
accountId: this.accountId,
|
||||||
|
ids: pageSubmissions.map(submission => submission.emailId),
|
||||||
|
properties: [
|
||||||
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
|
||||||
|
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
|
||||||
|
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
|
||||||
|
],
|
||||||
|
fetchTextBodyValues: true,
|
||||||
|
fetchHTMLBodyValues: true,
|
||||||
|
fetchAllBodyValues: true,
|
||||||
|
maxBodyValueBytes: 256000,
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
|
||||||
|
const emails = pageSubmissions
|
||||||
|
.map((submission): ScheduledEmail | null => {
|
||||||
|
const email = emailById.get(submission.emailId);
|
||||||
|
if (!email || !submission.sendAt) return null;
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
threadId: submission.threadId || email.threadId,
|
||||||
|
scheduledSendAt: submission.sendAt,
|
||||||
|
emailSubmissionId: submission.id,
|
||||||
|
scheduledIdentityId: submission.identityId,
|
||||||
|
scheduledUndoStatus: submission.undoStatus,
|
||||||
|
scheduledDeliveryStatus: submission.deliveryStatus,
|
||||||
|
isScheduled: true,
|
||||||
|
isSmimeScheduled: isSmimeEmail(email),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((email): email is ScheduledEmail => email !== null)
|
||||||
|
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
|
||||||
|
|
||||||
|
return { emails, hasMore: nextPosition < total, total, nextPosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/set', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
update: { [submissionId]: { undoStatus: 'canceled' } },
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
const error = result?.notUpdated?.[submissionId];
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
||||||
|
const holdForSeconds = this.validateDelayedUntil(delayedUntil);
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
const identities = await this.getIdentities();
|
||||||
|
const identity = identities.find(item => item.id === identityId);
|
||||||
|
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId);
|
||||||
|
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId);
|
||||||
|
const envelopeRecipients = existingEnvelope?.rcptTo?.length
|
||||||
|
? existingEnvelope.rcptTo
|
||||||
|
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
|
||||||
|
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/set', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
|
||||||
|
...(draftsMailbox && sentMailbox ? {
|
||||||
|
onSuccessUpdateEmail: {
|
||||||
|
'#replacement': {
|
||||||
|
[`mailboxIds/${draftsMailbox.id}`]: null,
|
||||||
|
[`mailboxIds/${sentMailbox.id}`]: true,
|
||||||
|
'keywords/$draft': null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
const createError = result?.notCreated?.replacement;
|
||||||
|
if (createError) {
|
||||||
|
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
|
||||||
|
}
|
||||||
|
const replacementId = result?.created?.replacement?.id;
|
||||||
|
const serverSendAt = result?.created?.replacement?.sendAt;
|
||||||
|
if (!replacementId) {
|
||||||
|
throw new Error('Server did not return a replacement scheduled send ID');
|
||||||
|
}
|
||||||
|
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
|
||||||
|
try {
|
||||||
|
await this.cancelEmailSubmission(submissionId);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await this.cancelEmailSubmission(replacementId);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
|
||||||
|
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
|
||||||
|
}
|
||||||
|
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
[`mailboxIds/${draftMailboxId}`]: true,
|
||||||
|
'keywords/$draft': true,
|
||||||
|
};
|
||||||
|
if (sentMailboxId) {
|
||||||
|
update[`mailboxIds/${sentMailboxId}`] = null;
|
||||||
|
}
|
||||||
|
const response = await this.request([
|
||||||
|
['Email/set', {
|
||||||
|
accountId: this.accountId,
|
||||||
|
update: { [emailId]: update },
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
const error = result?.notUpdated?.[emailId];
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5580,4 +6094,4 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
['urn:ietf:params:jmap:core'],
|
['urn:ietf:params:jmap:core'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -13,14 +13,21 @@ export interface SieveCapabilities {
|
|||||||
externalLists: string[];
|
externalLists: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
|
export type FilterConditionField =
|
||||||
|
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
||||||
|
| 'attachment';
|
||||||
|
|
||||||
export type FilterComparator =
|
export type FilterComparator =
|
||||||
| 'contains' | 'not_contains'
|
| 'contains' | 'not_contains'
|
||||||
| 'is' | 'not_is'
|
| 'is' | 'not_is'
|
||||||
| 'starts_with' | 'ends_with'
|
| 'starts_with' | 'ends_with'
|
||||||
| 'matches'
|
| 'matches'
|
||||||
| 'greater_than' | 'less_than';
|
| 'greater_than' | 'less_than'
|
||||||
|
// For field === 'attachment':
|
||||||
|
// has_any → message has any attachment (Content-Disposition: attachment)
|
||||||
|
// has_type → message has an attachment whose Content-Type matches `value`
|
||||||
|
// (substring match, e.g. "application/pdf" or "image/")
|
||||||
|
| 'has_any' | 'has_type';
|
||||||
|
|
||||||
export type FilterActionType =
|
export type FilterActionType =
|
||||||
| 'move' | 'copy' | 'forward'
|
| 'move' | 'copy' | 'forward'
|
||||||
@@ -30,7 +37,16 @@ export type FilterActionType =
|
|||||||
export interface FilterCondition {
|
export interface FilterCondition {
|
||||||
field: FilterConditionField;
|
field: FilterConditionField;
|
||||||
comparator: FilterComparator;
|
comparator: FilterComparator;
|
||||||
value: string;
|
/**
|
||||||
|
* Match value. Use a string array for OR-within-condition semantics
|
||||||
|
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
|
||||||
|
* Sieve emits the array as a list literal which the implementation
|
||||||
|
* treats as "matches any item". Use a plain string for single-value
|
||||||
|
* conditions; existing single-value rules continue to work unchanged.
|
||||||
|
*
|
||||||
|
* Not supported for: size (numeric), has_any (no value).
|
||||||
|
*/
|
||||||
|
value: string | string[];
|
||||||
headerName?: string;
|
headerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -42,6 +42,32 @@ export interface Email {
|
|||||||
// Unified mailbox support - set when displaying emails from multiple accounts
|
// Unified mailbox support - set when displaying emails from multiple accounts
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountLabel?: string;
|
accountLabel?: string;
|
||||||
|
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
|
||||||
|
scheduledSendAt?: string;
|
||||||
|
emailSubmissionId?: string;
|
||||||
|
scheduledIdentityId?: string;
|
||||||
|
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
|
||||||
|
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
|
||||||
|
isScheduled?: boolean;
|
||||||
|
isSmimeScheduled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendEmailResult {
|
||||||
|
scheduled: boolean;
|
||||||
|
emailId?: string;
|
||||||
|
emailSubmissionId?: string;
|
||||||
|
sendAt?: string;
|
||||||
|
isSmime?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledEmail extends Email {
|
||||||
|
scheduledSendAt: string;
|
||||||
|
emailSubmissionId: string;
|
||||||
|
scheduledIdentityId: string;
|
||||||
|
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
|
||||||
|
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
|
||||||
|
isScheduled: true;
|
||||||
|
isSmimeScheduled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticationResults {
|
export interface AuthenticationResults {
|
||||||
@@ -49,6 +75,16 @@ export interface AuthenticationResults {
|
|||||||
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
|
/**
|
||||||
|
* All SPF results when the server evaluated multiple identities (HELO and
|
||||||
|
* MAIL FROM). Present only when more than one result was found; `result`
|
||||||
|
* above is the most severe of these.
|
||||||
|
*/
|
||||||
|
all?: Array<{
|
||||||
|
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||||
|
domain?: string;
|
||||||
|
identity?: 'helo' | 'mailfrom';
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
dkim?: {
|
dkim?: {
|
||||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||||
@@ -462,6 +498,10 @@ export interface Calendar {
|
|||||||
// can route mutations to the right client. Distinct from `accountId`
|
// can route mutations to the right client. Distinct from `accountId`
|
||||||
// which is the JMAP server's own account UUID.
|
// which is the JMAP server's own account UUID.
|
||||||
localAccountId?: string;
|
localAccountId?: string;
|
||||||
|
// Set when `color` has been replaced by the viewer's local override for a
|
||||||
|
// shared calendar (see lib/shared-calendar-colors). When true, the override
|
||||||
|
// wins over per-event colors so the whole shared calendar paints uniformly.
|
||||||
|
colorIsLocalOverride?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarRights {
|
export interface CalendarRights {
|
||||||
@@ -785,4 +825,4 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
|
|||||||
|
|
||||||
export function isUnifiedMailboxId(id: string): boolean {
|
export function isUnifiedMailboxId(id: string): boolean {
|
||||||
return id in UNIFIED_ROLE_BY_ID;
|
return id in UNIFIED_ROLE_BY_ID;
|
||||||
}
|
}
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
|
||||||
|
// message string. JMAP/Stalwart has no native MDN support, so the client
|
||||||
|
// constructs the multipart/report itself and sends it via
|
||||||
|
// blob-upload -> Email/import -> EmailSubmission/set (see client.sendReadReceipt).
|
||||||
|
//
|
||||||
|
// The message has two parts:
|
||||||
|
// 1. text/plain — human-readable explanation (English, ASCII; rarely shown)
|
||||||
|
// 2. message/disposition-notification — the machine-readable fields
|
||||||
|
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
|
||||||
|
// permits a two-part report.
|
||||||
|
|
||||||
|
export interface MdnOptions {
|
||||||
|
/** Address that requested the receipt (Disposition-Notification-To) — the MDN recipient. */
|
||||||
|
to: string;
|
||||||
|
/** Our identity address (sender of the MDN). */
|
||||||
|
fromEmail: string;
|
||||||
|
/** Optional display name for the From header. */
|
||||||
|
fromName?: string;
|
||||||
|
/** Original Message-ID. JMAP may hand this back as a string[]
|
||||||
|
* (header:Message-ID:asMessageIds), so accept both. */
|
||||||
|
originalMessageId?: string | string[];
|
||||||
|
/** Original Subject (used to build the MDN subject). */
|
||||||
|
originalSubject?: string;
|
||||||
|
/**
|
||||||
|
* The address the original message was delivered to (our address/alias).
|
||||||
|
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
|
||||||
|
*/
|
||||||
|
originalRecipient?: string;
|
||||||
|
/** true => automatic-action (setting "always"); false => manual-action (user clicked send). */
|
||||||
|
automatic?: boolean;
|
||||||
|
/** Reporting-UA value, e.g. "mail.dornig.de; Bulwark Webmail". */
|
||||||
|
reportingUa?: string;
|
||||||
|
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
|
||||||
|
subject?: string;
|
||||||
|
/** Localized human-readable explanation (first report part). Defaults to English. */
|
||||||
|
humanText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
|
||||||
|
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
|
||||||
|
function rfc5322Date(d: Date = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
|
||||||
|
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 string -> base64, without the deprecated unescape(). */
|
||||||
|
function utf8ToBase64(value: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
|
||||||
|
function base64Body(text: string): string {
|
||||||
|
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
|
||||||
|
function encodeHeaderWord(value: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (!/[^\x00-\x7F]/.test(value)) return value;
|
||||||
|
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAngles(messageId: string | string[] | undefined): string {
|
||||||
|
// JMAP often returns Message-ID as a string[] (header:...:asMessageIds), so
|
||||||
|
// normalize string | string[] | undefined down to a single bracketed id.
|
||||||
|
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
|
||||||
|
if (typeof raw !== "string") return "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomToken(): string {
|
||||||
|
const rnd = Math.random().toString(36).slice(2);
|
||||||
|
return `${Date.now().toString(36)}.${rnd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
|
||||||
|
* by the MIME standard so the bytes import/transmit verbatim.
|
||||||
|
*/
|
||||||
|
export function buildMdnMessage(opts: MdnOptions): string {
|
||||||
|
const finalRecipient = opts.originalRecipient || opts.fromEmail;
|
||||||
|
const domain = (opts.fromEmail.split("@")[1] || "localhost").trim();
|
||||||
|
const messageId = `<mdn.${randomToken()}@${domain}>`;
|
||||||
|
const boundary = `----=_MDN_${randomToken()}`;
|
||||||
|
const origMsgId = ensureAngles(opts.originalMessageId); // normalized "<...>" or ""
|
||||||
|
|
||||||
|
const fromHeader = opts.fromName
|
||||||
|
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
|
||||||
|
: opts.fromEmail;
|
||||||
|
|
||||||
|
const subject = encodeHeaderWord(
|
||||||
|
opts.subject ?? `Read: ${opts.originalSubject || ""}`.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const disposition = opts.automatic
|
||||||
|
? "automatic-action/MDN-sent-automatically; displayed"
|
||||||
|
: "manual-action/MDN-sent-manually; displayed";
|
||||||
|
|
||||||
|
const reportingUa = opts.reportingUa || `${domain}; Bulwark Webmail`;
|
||||||
|
|
||||||
|
// Human-readable part. Caller passes a localized humanText; fall back to
|
||||||
|
// English. Encoded as UTF-8/base64 below so any language survives.
|
||||||
|
const humanText = opts.humanText ?? [
|
||||||
|
`This is a return receipt for the message you sent to ${finalRecipient}.`,
|
||||||
|
``,
|
||||||
|
`Note: This receipt only acknowledges that the message was displayed on the`,
|
||||||
|
`recipient's computer. There is no guarantee that the recipient has read or`,
|
||||||
|
`understood the message contents.`,
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
// Machine-readable disposition-notification part (pure ASCII tokens).
|
||||||
|
const mdnFields = [
|
||||||
|
`Reporting-UA: ${reportingUa}`,
|
||||||
|
`Final-Recipient: rfc822;${finalRecipient}`,
|
||||||
|
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
|
||||||
|
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
|
||||||
|
`Disposition: ${disposition}`,
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Date: ${rfc5322Date()}`,
|
||||||
|
`From: ${fromHeader}`,
|
||||||
|
`To: ${opts.to}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`Message-ID: ${messageId}`,
|
||||||
|
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
|
||||||
|
`MIME-Version: 1.0`,
|
||||||
|
`Content-Type: multipart/report; report-type=disposition-notification;`,
|
||||||
|
`\tboundary="${boundary}"`,
|
||||||
|
``,
|
||||||
|
`--${boundary}`,
|
||||||
|
`Content-Type: text/plain; charset=utf-8`,
|
||||||
|
`Content-Transfer-Encoding: base64`,
|
||||||
|
``,
|
||||||
|
base64Body(humanText),
|
||||||
|
``,
|
||||||
|
`--${boundary}`,
|
||||||
|
`Content-Type: message/disposition-notification`,
|
||||||
|
`Content-Transfer-Encoding: 7bit`,
|
||||||
|
``,
|
||||||
|
mdnFields,
|
||||||
|
``,
|
||||||
|
`--${boundary}--`,
|
||||||
|
``,
|
||||||
|
].join("\r\n");
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ export function getDiscoveryValidator(): EndpointValidator | undefined {
|
|||||||
function getGlobalClientSecret(): string {
|
function getGlobalClientSecret(): string {
|
||||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||||
if (adminSecret) return adminSecret;
|
if (adminSecret) return adminSecret;
|
||||||
|
|
||||||
|
const adminFileSecret = readFileEnv(
|
||||||
|
configManager.get<string>('oauthClientSecretFile', ''),
|
||||||
|
);
|
||||||
|
if (adminFileSecret) return adminFileSecret;
|
||||||
|
|
||||||
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-19
@@ -10,32 +10,25 @@ import {
|
|||||||
activateAllSandboxed,
|
activateAllSandboxed,
|
||||||
deactivateAllSandboxed,
|
deactivateAllSandboxed,
|
||||||
setSandboxStoreAccessor,
|
setSandboxStoreAccessor,
|
||||||
setSandboxLocale,
|
|
||||||
setupSandboxAutoDisable,
|
setupSandboxAutoDisable,
|
||||||
} from './plugin-sandbox/loader';
|
} from './plugin-sandbox/loader';
|
||||||
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||||
|
|
||||||
|
// Re-export so the plugin store can keep the sandbox locale in step via this
|
||||||
|
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
|
||||||
|
// also pull the hook buses into consumers' module graphs).
|
||||||
|
export { setSandboxLocale } from './plugin-sandbox/loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
|
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
|
||||||
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
* loader, and later also bootstrapped plugin locale sync. Both are obsolete:
|
||||||
* plugins receive React injected as a function argument inside their iframe
|
* the sandbox injects React per-iframe, and locale sync now lives where plugin
|
||||||
* runtime - there is nothing to expose on the host window.
|
* activation is orchestrated (stores/plugin-store -> initializePlugins, via
|
||||||
*
|
* setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins()
|
||||||
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
* wrapper and its test.
|
||||||
*/
|
*/
|
||||||
export function exposePluginExternals(): void {
|
export function exposePluginExternals(): void {
|
||||||
if (typeof window === 'undefined') return;
|
/* no-op */
|
||||||
// Initialise the locale sync once. Importing the store lazily avoids the
|
|
||||||
// circular module graph we used to fight before the sandbox refactor.
|
|
||||||
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
|
||||||
setSandboxLocale(useLocaleStore.getState().locale);
|
|
||||||
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
|
|
||||||
// Mirror on a global so the slot-iframe component can read it at spawn.
|
|
||||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
|
|
||||||
useLocaleStore.subscribe((state) => {
|
|
||||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
|
|
||||||
});
|
|
||||||
}).catch(() => { /* locale sync is best-effort */ });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Store accessor (status updates) ──────────────────────────
|
// ─── Store accessor (status updates) ──────────────────────────
|
||||||
|
|||||||
@@ -5,6 +5,40 @@
|
|||||||
import type { Email } from '@/lib/jmap/types';
|
import type { Email } from '@/lib/jmap/types';
|
||||||
import type { EmailReadView } from '@/lib/plugin-types';
|
import type { EmailReadView } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
|
||||||
|
// translate or scan content need the real body, not just the short `preview`
|
||||||
|
// snippet. Prefers text/plain parts; falls back to stripped HTML, then preview.
|
||||||
|
function plainTextFromEmail(email: Email): string {
|
||||||
|
const values = email.bodyValues || {};
|
||||||
|
const collect = (parts?: { partId: string }[]) =>
|
||||||
|
(parts || [])
|
||||||
|
.map((p) => values[p.partId]?.value)
|
||||||
|
.filter((v): v is string => typeof v === 'string' && v.length > 0)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const text = collect(email.textBody);
|
||||||
|
if (text) return text;
|
||||||
|
|
||||||
|
const html = collect(email.htmlBody);
|
||||||
|
if (html) {
|
||||||
|
return html
|
||||||
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/'|'/gi, "'")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/[ \t]{2,}/g, ' ')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (email.preview || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function emailToReadView(email: Email): EmailReadView {
|
export function emailToReadView(email: Email): EmailReadView {
|
||||||
return {
|
return {
|
||||||
id: email.id,
|
id: email.id,
|
||||||
@@ -19,6 +53,7 @@ export function emailToReadView(email: Email): EmailReadView {
|
|||||||
isFlagged: !!email.keywords?.['$flagged'],
|
isFlagged: !!email.keywords?.['$flagged'],
|
||||||
hasAttachment: email.hasAttachment,
|
hasAttachment: email.hasAttachment,
|
||||||
preview: email.preview || '',
|
preview: email.preview || '',
|
||||||
|
text: plainTextFromEmail(email),
|
||||||
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
|
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
|
||||||
auth: email.authenticationResults,
|
auth: email.authenticationResults,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
|
|||||||
headers['Authorization'] = client.getAuthHeader();
|
headers['Authorization'] = client.getAuthHeader();
|
||||||
headers['X-JMAP-Username'] = client.getUsername();
|
headers['X-JMAP-Username'] = client.getUsername();
|
||||||
}
|
}
|
||||||
const res = await fetch(url.pathname + url.search, {
|
const res = await apiFetch(url.pathname + url.search, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
import type { InstalledPlugin, SlotName } from '../plugin-types';
|
import type { InstalledPlugin, SlotName } from '../plugin-types';
|
||||||
import { dispatchApiCall } from './host-api';
|
import { dispatchApiCall } from './host-api';
|
||||||
import { SANDBOX_PATH } from './protocol';
|
import { SANDBOX_PATH } from './protocol';
|
||||||
|
import { withBasePath } from '../browser-navigation';
|
||||||
|
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||||
import type {
|
import type {
|
||||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||||
} from './protocol';
|
} from './protocol';
|
||||||
@@ -143,7 +145,10 @@ export class SandboxInstance {
|
|||||||
this.iframe.style.width = '100%';
|
this.iframe.style.width = '100%';
|
||||||
this.iframe.style.height = '0px';
|
this.iframe.style.height = '0px';
|
||||||
}
|
}
|
||||||
this.iframe.src = SANDBOX_PATH;
|
// Prefix with the mount path so the sandbox route resolves under a
|
||||||
|
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
|
||||||
|
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
|
||||||
|
this.iframe.src = withBasePath(SANDBOX_PATH);
|
||||||
|
|
||||||
this.listener = (ev) => this.onMessage(ev);
|
this.listener = (ev) => this.onMessage(ev);
|
||||||
window.addEventListener('message', this.listener);
|
window.addEventListener('message', this.listener);
|
||||||
@@ -274,6 +279,12 @@ export class SandboxInstance {
|
|||||||
this.send({ type: 'locale-change', locale });
|
this.send({ type: 'locale-change', locale });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Push a new resolved theme so the slot iframe re-injects its theme CSS. */
|
||||||
|
setTheme(theme: ThemeSnapshot): void {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.send({ type: 'theme-change', theme });
|
||||||
|
}
|
||||||
|
|
||||||
updateProps(props: Record<string, unknown>): void {
|
updateProps(props: Record<string, unknown>): void {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
// Stale references would leak if we kept growing the table without
|
// Stale references would leak if we kept growing the table without
|
||||||
@@ -342,6 +353,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
|||||||
},
|
},
|
||||||
extraProps: opts.extraProps,
|
extraProps: opts.extraProps,
|
||||||
locale: opts.locale,
|
locale: opts.locale,
|
||||||
|
theme: snapshotHostTheme(),
|
||||||
};
|
};
|
||||||
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
|
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Theme bridge for plugin slot iframes.
|
||||||
|
//
|
||||||
|
// Slot iframes run with an opaque ("null") origin, so they can't load the
|
||||||
|
// host's globals.css or web fonts cross-origin (see app/(sandbox)/layout.tsx).
|
||||||
|
// The result: plugin slot UIs fall back to the UA default serif font and have
|
||||||
|
// no knowledge of the host's light/dark (or custom) theme.
|
||||||
|
//
|
||||||
|
// To fix that without any cross-origin asset fetch, the host snapshots the
|
||||||
|
// *resolved* theme — the computed `--color-*` token values, the resolved
|
||||||
|
// font-family (which is a pure system-font stack, so no fetch is needed), and
|
||||||
|
// whether dark mode is active — and ships it across the postMessage bridge.
|
||||||
|
// The sandbox runtime replays the snapshot as an injected <style> block plus a
|
||||||
|
// `.dark` class, so every plugin slot inherits the app font and can react to
|
||||||
|
// theme changes by reading `var(--color-*)`.
|
||||||
|
|
||||||
|
/** A replayable description of the host's currently resolved theme. */
|
||||||
|
export interface ThemeSnapshot {
|
||||||
|
/** Whether the host has dark mode active (`.dark` on <html>). */
|
||||||
|
dark: boolean;
|
||||||
|
/** Resolved font-family stack (system fonts only — safe to replay verbatim). */
|
||||||
|
fontFamily: string;
|
||||||
|
/** Resolved values for each mirrored CSS custom property. */
|
||||||
|
vars: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS custom properties mirrored into plugin slots. Kept in sync with the
|
||||||
|
// `:root` token block in app/globals.css. Both the colour tokens (the stable
|
||||||
|
// theming API surface) and the tier-2 typography/density tokens are included so
|
||||||
|
// plugins can match the host's metrics, not just its colours.
|
||||||
|
const THEME_TOKENS: readonly string[] = [
|
||||||
|
'--color-border',
|
||||||
|
'--color-input',
|
||||||
|
'--color-ring',
|
||||||
|
'--color-background',
|
||||||
|
'--color-foreground',
|
||||||
|
'--color-primary',
|
||||||
|
'--color-primary-foreground',
|
||||||
|
'--color-secondary',
|
||||||
|
'--color-secondary-foreground',
|
||||||
|
'--color-muted',
|
||||||
|
'--color-muted-foreground',
|
||||||
|
'--color-accent',
|
||||||
|
'--color-accent-foreground',
|
||||||
|
'--color-destructive',
|
||||||
|
'--color-destructive-foreground',
|
||||||
|
'--color-popover',
|
||||||
|
'--color-popover-foreground',
|
||||||
|
'--color-sidebar',
|
||||||
|
'--color-sidebar-foreground',
|
||||||
|
'--color-sidebar-border',
|
||||||
|
'--color-sidebar-accent',
|
||||||
|
'--color-sidebar-accent-foreground',
|
||||||
|
'--color-card',
|
||||||
|
'--color-card-foreground',
|
||||||
|
'--color-success',
|
||||||
|
'--color-success-foreground',
|
||||||
|
'--color-warning',
|
||||||
|
'--color-warning-foreground',
|
||||||
|
'--color-info',
|
||||||
|
'--color-info-foreground',
|
||||||
|
'--color-selection',
|
||||||
|
'--color-selection-foreground',
|
||||||
|
'--color-unread',
|
||||||
|
'--color-chart-1',
|
||||||
|
'--color-chart-2',
|
||||||
|
'--color-chart-3',
|
||||||
|
'--color-chart-4',
|
||||||
|
'--color-chart-5',
|
||||||
|
'--font-size-base',
|
||||||
|
'--list-item-height',
|
||||||
|
'--transition-duration',
|
||||||
|
'--density-item-py',
|
||||||
|
'--density-item-gap',
|
||||||
|
'--density-header-py',
|
||||||
|
'--density-card-p',
|
||||||
|
'--density-sidebar-py',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mirrors the body font stack in app/globals.css. Used when no document is
|
||||||
|
// available (SSR) or the body has no resolvable font-family yet.
|
||||||
|
const FALLBACK_FONT_FAMILY =
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Thai", "Leelawadee UI", Tahoma, sans-serif';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the host's resolved theme off `<html>`. Reading computed styles means
|
||||||
|
* the snapshot automatically reflects the active built-in *and* custom theme,
|
||||||
|
* not just the static globals.css defaults.
|
||||||
|
*/
|
||||||
|
export function snapshotHostTheme(): ThemeSnapshot {
|
||||||
|
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||||
|
return { dark: false, fontFamily: FALLBACK_FONT_FAMILY, vars: {} };
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
const computed = getComputedStyle(root);
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const token of THEME_TOKENS) {
|
||||||
|
const value = computed.getPropertyValue(token).trim();
|
||||||
|
if (value) vars[token] = value;
|
||||||
|
}
|
||||||
|
const bodyFont = document.body ? getComputedStyle(document.body).fontFamily : '';
|
||||||
|
return {
|
||||||
|
dark: root.classList.contains('dark'),
|
||||||
|
fontFamily: bodyFont || FALLBACK_FONT_FAMILY,
|
||||||
|
vars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the CSS the sandbox injects to replay a snapshot. Pure (no DOM access)
|
||||||
|
* so it can run inside the iframe runtime. The `:root` block restores the
|
||||||
|
* host's token values; the `html, body` block gives plugin slots the app font
|
||||||
|
* and a theme-aware default text colour with a transparent background (the
|
||||||
|
* host's themed container shows through).
|
||||||
|
*/
|
||||||
|
export function themeSnapshotToCSS(snapshot: ThemeSnapshot): string {
|
||||||
|
const declarations = Object.entries(snapshot.vars)
|
||||||
|
.map(([name, value]) => ` ${name}: ${value};`)
|
||||||
|
.join('\n');
|
||||||
|
const colorScheme = snapshot.dark ? 'dark' : 'light';
|
||||||
|
return [
|
||||||
|
':root {',
|
||||||
|
declarations,
|
||||||
|
` color-scheme: ${colorScheme};`,
|
||||||
|
'}',
|
||||||
|
'html, body {',
|
||||||
|
` font-family: ${snapshot.fontFamily};`,
|
||||||
|
' color: var(--color-foreground, inherit);',
|
||||||
|
' background: transparent;',
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
|
|||||||
|
|
||||||
let currentLocale = 'en';
|
let currentLocale = 'en';
|
||||||
export function setSandboxLocale(locale: string): void {
|
export function setSandboxLocale(locale: string): void {
|
||||||
|
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
|
||||||
|
// good locale back to '' - the initial 'en' default stands until the real
|
||||||
|
// locale arrives via the store subscription.
|
||||||
|
if (!locale) return;
|
||||||
currentLocale = locale;
|
currentLocale = locale;
|
||||||
// Push to all active background instances.
|
// Background instances read `currentLocale` at load time; the slot-iframe
|
||||||
// Slot iframes inherit locale at spawn time; they're short-lived.
|
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
|
||||||
// (We don't import the registry here to avoid a circular import; the
|
// both in step from one place. Already-running instances are not re-pushed,
|
||||||
// PluginIframeSlot subscribes to locale changes on its own.)
|
// so a locale switch only affects plugins/slots loaded afterwards.
|
||||||
|
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Bundle fetch ─────────────────────────────────────────────
|
// ─── Bundle fetch ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// class instances.
|
// class instances.
|
||||||
|
|
||||||
import type { SlotName } from '../plugin-types';
|
import type { SlotName } from '../plugin-types';
|
||||||
|
import type { ThemeSnapshot } from './host-theme';
|
||||||
|
|
||||||
// ─── Sandbox mode ────────────────────────────────────────────
|
// ─── Sandbox mode ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,6 +63,12 @@ export interface SlotInit {
|
|||||||
*/
|
*/
|
||||||
extraProps: Record<string, unknown>;
|
extraProps: Record<string, unknown>;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
/**
|
||||||
|
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
|
||||||
|
* can't load globals.css/fonts cross-origin, so the runtime replays this as
|
||||||
|
* injected CSS + a `.dark` class. Host pushes updates via 'theme-change'.
|
||||||
|
*/
|
||||||
|
theme: ThemeSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InitPayload = BackgroundInit | SlotInit;
|
export type InitPayload = BackgroundInit | SlotInit;
|
||||||
@@ -163,6 +170,9 @@ export interface HookInvokeMsg {
|
|||||||
|
|
||||||
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
|
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
|
||||||
|
|
||||||
|
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
|
||||||
|
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
|
||||||
|
|
||||||
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
|
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
|
||||||
|
|
||||||
export interface SlotShouldShowMsg {
|
export interface SlotShouldShowMsg {
|
||||||
@@ -178,6 +188,7 @@ export type HostToSandbox =
|
|||||||
| CallbackResponseMsg
|
| CallbackResponseMsg
|
||||||
| HookInvokeMsg
|
| HookInvokeMsg
|
||||||
| LocaleChangeMsg
|
| LocaleChangeMsg
|
||||||
|
| ThemeChangeMsg
|
||||||
| PropsUpdateMsg
|
| PropsUpdateMsg
|
||||||
| SlotShouldShowMsg;
|
| SlotShouldShowMsg;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
BackgroundInit,
|
BackgroundInit,
|
||||||
SlotInit,
|
SlotInit,
|
||||||
} from './protocol';
|
} from './protocol';
|
||||||
|
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||||
import type { SlotName } from '../plugin-types';
|
import type { SlotName } from '../plugin-types';
|
||||||
|
|
||||||
// ─── Module-scope state ──────────────────────────────────────
|
// ─── Module-scope state ──────────────────────────────────────
|
||||||
@@ -67,24 +68,54 @@ function sendToHost(msg: SandboxToHost): void {
|
|||||||
parentWindow.postMessage(msg, parentOrigin);
|
parentWindow.postMessage(msg, parentOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Theme replay ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const THEME_STYLE_ID = '__plugin_host_theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay a host theme snapshot inside the iframe: inject the token + font CSS
|
||||||
|
* and mirror the `.dark` class onto <html> so plugin styles that key off
|
||||||
|
* `.dark` (or read `var(--color-*)`) behave like the host. Idempotent — safe
|
||||||
|
* to call again on every 'theme-change'.
|
||||||
|
*/
|
||||||
|
function applyHostTheme(theme: ThemeSnapshot): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = THEME_STYLE_ID;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = themeSnapshotToCSS(theme);
|
||||||
|
document.documentElement.classList.toggle('dark', theme.dark);
|
||||||
|
}
|
||||||
|
|
||||||
function uid(): string {
|
function uid(): string {
|
||||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
||||||
|
|
||||||
function callApi(method: string, args: unknown[]): Promise<unknown> {
|
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
pendingApi.set(id, { resolve, reject });
|
pendingApi.set(id, { resolve, reject });
|
||||||
sendToHost({ type: 'api-request', id, method, args });
|
sendToHost({ type: 'api-request', id, method, args });
|
||||||
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
|
// Bounded so a hung host can't leak the promise forever. Interactive UI
|
||||||
setTimeout(() => {
|
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
|
||||||
const entry = pendingApi.get(id);
|
// for human input, the host always resolves them on confirm/cancel/close,
|
||||||
if (!entry) return;
|
// and any still-pending call dies with the iframe on teardown - so there's
|
||||||
pendingApi.delete(id);
|
// nothing to leak, and a thinking user must not trip a 30s timeout.
|
||||||
entry.reject(new Error(`API call ${method} timed out after 30s`));
|
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
|
||||||
}, 30_000);
|
setTimeout(() => {
|
||||||
|
const entry = pendingApi.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
pendingApi.delete(id);
|
||||||
|
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,12 +182,13 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
|
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
|
||||||
|
* No timeout - it waits for the user's choice. */
|
||||||
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
||||||
callApi('ui.confirm', [opts]) as Promise<boolean>,
|
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
|
||||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
|
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
||||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||||
callApi('ui.alert', [opts]) as Promise<void>,
|
callApi('ui.alert', [opts], 0) as Promise<void>,
|
||||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||||
openExternalUrl: (url: string, target?: string) =>
|
openExternalUrl: (url: string, target?: string) =>
|
||||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||||
@@ -173,6 +205,26 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
||||||
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
|
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
|
||||||
},
|
},
|
||||||
|
// Localization for plugins. The host pushes the active locale (init +
|
||||||
|
// 'locale-change'); `t` resolves a key against the plugin's declared
|
||||||
|
// `locales` map (manifest.locales), falling back to English then the key
|
||||||
|
// itself, with optional {placeholder} interpolation.
|
||||||
|
i18n: {
|
||||||
|
get locale(): string {
|
||||||
|
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||||
|
},
|
||||||
|
t(key: string, vars?: Record<string, string | number>): string {
|
||||||
|
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||||
|
const tables = manifest.locales || {};
|
||||||
|
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
|
||||||
|
if (vars) {
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
out = out.split('{' + k + '}').join(String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +333,10 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bootSlot(payload: SlotInit): void {
|
function bootSlot(payload: SlotInit): void {
|
||||||
|
// Replay the host theme before first paint so the slot never flashes the UA
|
||||||
|
// default serif font or a light-on-light/dark mismatch.
|
||||||
|
applyHostTheme(payload.theme);
|
||||||
|
|
||||||
const api = buildPluginApi(payload.manifest);
|
const api = buildPluginApi(payload.manifest);
|
||||||
const exports = evaluateBundle(payload.code, api);
|
const exports = evaluateBundle(payload.code, api);
|
||||||
pluginExports = exports;
|
pluginExports = exports;
|
||||||
@@ -344,6 +400,9 @@ async function handleInit(payload: InitPayload): Promise<void> {
|
|||||||
if (bootDone) return;
|
if (bootDone) return;
|
||||||
bootDone = true;
|
bootDone = true;
|
||||||
mode = payload.mode;
|
mode = payload.mode;
|
||||||
|
// Make the active locale available to plugin code (api.i18n) right away -
|
||||||
|
// not only after the first 'locale-change' push.
|
||||||
|
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
|
||||||
try {
|
try {
|
||||||
if (payload.mode === 'background') {
|
if (payload.mode === 'background') {
|
||||||
await bootBackground(payload);
|
await bootBackground(payload);
|
||||||
@@ -430,6 +489,10 @@ function handleHostMessage(ev: MessageEvent): void {
|
|||||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'theme-change':
|
||||||
|
applyHostTheme(msg.theme);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'props-update':
|
case 'props-update':
|
||||||
slotPropsUpdater?.(msg.props ?? {});
|
slotPropsUpdater?.(msg.props ?? {});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -374,6 +374,10 @@ export interface EmailReadView {
|
|||||||
hasAttachment: boolean;
|
hasAttachment: boolean;
|
||||||
preview: string;
|
preview: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
|
/** Full plain-text body of the message (HTML-only bodies are stripped to
|
||||||
|
* text). Empty string when the host hasn't loaded the body. Same
|
||||||
|
* `email:read` sensitivity as the rest of this view. */
|
||||||
|
text: string;
|
||||||
/**
|
/**
|
||||||
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
|
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
|
||||||
* Absent on stores that didn't parse the header (e.g. bodies not yet
|
* Absent on stores that didn't parse the header (e.g. bodies not yet
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user