Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 | ||
|
|
de56229ef2 | ||
|
|
1ff23790ae | ||
|
|
198407ebf5 | ||
|
|
de68d68fb7 | ||
|
|
f285a2bd64 | ||
|
|
84b6d0fd8b | ||
|
|
f972068143 | ||
|
|
7882b254a0 | ||
|
|
ae5d397512 | ||
|
|
7a022596c3 | ||
|
|
8c575e8ed8 | ||
|
|
85fbab9eb4 | ||
|
|
1119d8ed73 | ||
|
|
32f0a67dbc | ||
|
|
baf094d026 | ||
|
|
f1b9f4ba50 | ||
|
|
cc20d29636 | ||
|
|
5f713a033b | ||
|
|
079ec57204 | ||
|
|
cd247e9c47 | ||
|
|
8af6694152 | ||
|
|
751f3c1685 | ||
|
|
5c2f206c74 | ||
|
|
acc61db6f2 | ||
|
|
d671c606a1 | ||
|
|
fa3c57467b | ||
|
|
befee332d2 | ||
|
|
a29c33b50a | ||
|
|
3dd596ba50 | ||
|
|
e50fe0d4db | ||
|
|
3d3ad8f0ef | ||
|
|
d0ed4b4dfe | ||
|
|
5306f7c548 | ||
|
|
ddb596affc | ||
|
|
bfc8ba851a | ||
|
|
3f9e60843d | ||
|
|
2fac6ebfb8 | ||
|
|
dda9fd1433 | ||
|
|
3516d3c727 | ||
|
|
0b7203df0f | ||
|
|
344795a8d9 | ||
|
|
638fc7db4e | ||
|
|
ab3e0e717a | ||
|
|
c9eae3b3a1 | ||
|
|
6f615f4c32 | ||
|
|
52eacf87b9 | ||
|
|
c4f1cc23d7 | ||
|
|
4f1390fdeb | ||
|
|
c0ca6d3102 | ||
|
|
0872d3dc8d | ||
|
|
1f889b0965 | ||
|
|
0b9fe5451f | ||
|
|
fd700f412e | ||
|
|
f7d4f9d53c | ||
|
|
84aced7b4e | ||
|
|
94b1f5aa48 | ||
|
|
c51c3655d5 | ||
|
|
404a1e847c | ||
|
|
dcea4fdd5e | ||
|
|
701f96adb3 | ||
|
|
1ebfb286ad | ||
|
|
b5f15dfdb7 | ||
|
|
aee4bd78db | ||
|
|
798a33495e | ||
|
|
4c6c1aab60 | ||
|
|
848ed9774d | ||
|
|
b32e102ff9 | ||
|
|
e8feb11983 | ||
|
|
3e12fca517 | ||
|
|
8df483d8c7 | ||
|
|
1e63e2469a | ||
|
|
e1c28e767a | ||
|
|
fe5645c818 | ||
|
|
38570b1723 | ||
|
|
a4f476945d | ||
|
|
20fd9ff4de | ||
|
|
569dde9985 | ||
|
|
08f344403b | ||
|
|
be58cee989 | ||
|
|
7c11e2b3c9 | ||
|
|
f2913a7c7d | ||
|
|
a27a5be3dd | ||
|
|
964136b540 | ||
|
|
569f688fbf | ||
|
|
1d050f8469 | ||
|
|
2e4c0f9eea | ||
|
|
fd0b866339 | ||
|
|
1518ba04dd | ||
|
|
c5672c64fb | ||
|
|
5997579f54 | ||
|
|
f77d2e9103 | ||
|
|
6350e9dabc | ||
|
|
7723c134ff | ||
|
|
d11ed904a9 | ||
|
|
9db7b6b55f | ||
|
|
1460706e60 | ||
|
|
27d624758a | ||
|
|
5288821605 | ||
|
|
58e3ecc97a | ||
|
|
38c0694a08 | ||
|
|
71d25bb62f | ||
|
|
ef825b801a | ||
|
|
50cbd66bfd | ||
|
|
d564c874a3 | ||
|
|
568abb0e33 | ||
|
|
44781f002c | ||
|
|
941fa15251 | ||
|
|
60c7bd713e | ||
|
|
6f193e0c24 | ||
|
|
6bb85d746c | ||
|
|
bbd43b5948 | ||
|
|
180331805f | ||
|
|
9e96b1c24e | ||
|
|
40b4b26074 | ||
|
|
9863b9d88e | ||
|
|
75602b6a00 | ||
|
|
22da11514b | ||
|
|
9953557af0 | ||
|
|
8f7066d194 | ||
|
|
75c02f443f | ||
|
|
31100b8f87 | ||
|
|
152ec99262 | ||
|
|
ce401c0f59 | ||
|
|
3035fb046f | ||
|
|
4659b81538 | ||
|
|
c78dbee60b | ||
|
|
4b4c801148 | ||
|
|
2e4d3d4bc6 | ||
|
|
26ccf9e3b4 | ||
|
|
bc322a1e69 | ||
|
|
6ee3849463 | ||
|
|
abb249e5df | ||
|
|
0352312f25 | ||
|
|
ae66f8d89d | ||
|
|
55be19ede7 | ||
|
|
b508551d02 | ||
|
|
7ee329e046 | ||
|
|
1512b9afc0 | ||
|
|
ad48f4394a | ||
|
|
8d79145dba | ||
|
|
b821f8cc27 | ||
|
|
bebb394f54 | ||
|
|
2ba0003e16 | ||
|
|
4c1d0931a1 | ||
|
|
5a70cf95e0 | ||
|
|
66c5f0f52c | ||
|
|
8353b28b33 | ||
|
|
229992853b | ||
|
|
f0d87d594a | ||
|
|
196e51e91b | ||
|
|
0879030dc8 | ||
|
|
05e2837f6b | ||
|
|
241544cd08 | ||
|
|
7341e47a1b | ||
|
|
f238ca5898 | ||
|
|
00b40fc48a | ||
|
|
856496715b | ||
|
|
eb7eeae1ac | ||
|
|
6b1a99a70b | ||
|
|
1da04c254b | ||
|
|
8ae5ecba41 | ||
|
|
4ff05bd4ec | ||
|
|
31e96d6a46 | ||
|
|
82be047708 | ||
|
|
7d1fb73290 | ||
|
|
2818a16f06 | ||
|
|
e93dd44111 | ||
|
|
e86183b44a | ||
|
|
ad80aa23ca | ||
|
|
3a8daf8bff | ||
|
|
f8e7cce85a | ||
|
|
62ebe443f9 | ||
|
|
3c0faba837 | ||
|
|
956acb69ce | ||
|
|
e2abc8dee9 | ||
|
|
42ec34be21 | ||
|
|
534894c38d | ||
|
|
537707d9ed | ||
|
|
afe1e5a67c | ||
|
|
d13934c2a6 | ||
|
|
acc90eb455 | ||
|
|
5aa6d7a2f0 | ||
|
|
c46de636e0 | ||
|
|
8de8babba5 | ||
|
|
63f2169ae7 | ||
|
|
e843ef0ebb | ||
|
|
4b463f9691 | ||
|
|
58e4a3d117 | ||
|
|
e3f6ae874d | ||
|
|
52f5a5b42c | ||
|
|
e7bded82fb | ||
|
|
3ac14ecf38 | ||
|
|
0dca019fe5 | ||
|
|
ca0d6805cf | ||
|
|
8bcb487442 | ||
|
|
0245ec67e1 | ||
|
|
8810a63262 | ||
|
|
d3778e6521 | ||
|
|
1fc6185002 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec |
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
# LOGIN_WEBSITE_URL=https://example.com
|
||||
|
||||
# Per-domain branding overrides. Each entry must have "host" (exact or
|
||||
# "*.subdomain" wildcard) plus any subset of branding fields to override.
|
||||
# Unset fields fall through to the global values above.
|
||||
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
|
||||
|
||||
+57
-4
@@ -49,6 +49,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
|
||||
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
|
||||
# Leave unset to use the authorization_endpoint from discovery.
|
||||
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
|
||||
|
||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
@@ -105,12 +115,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Anonymous Telemetry
|
||||
# =============================================================================
|
||||
|
||||
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
||||
# version, platform, bucketed account counts, and feature toggles only. See
|
||||
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
|
||||
# helps us understand how Bulwark is used so we can make the product better.
|
||||
# Heartbeats contain no PII: version, platform, bucketed account counts, and
|
||||
# feature toggles only - never email addresses, hostnames, or IPs. See
|
||||
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||
#
|
||||
# Disable telemetry entirely (overrides the admin UI):
|
||||
# BULWARK_TELEMETRY=off
|
||||
# Enable telemetry (also toggleable in the admin UI):
|
||||
# BULWARK_TELEMETRY=on
|
||||
#
|
||||
# Setting this (on or off) locks the choice and disables the admin UI toggle.
|
||||
|
||||
# Directory for telemetry state: instance id, consent, login HMACs
|
||||
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||
@@ -220,6 +234,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-domain branding overrides (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When you serve the webmail on multiple hostnames, each hostname can override
|
||||
# a subset of branding fields. Unset fields fall back to the global values
|
||||
# above. Match is on the request's Host (or X-Forwarded-Host) header.
|
||||
#
|
||||
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
|
||||
# matches mail.example.com and any deeper subdomain, but NOT example.com).
|
||||
# Exact matches always win over wildcards; the longest wildcard suffix wins
|
||||
# among multiple wildcard matches.
|
||||
#
|
||||
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
|
||||
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
|
||||
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
|
||||
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
|
||||
#
|
||||
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
|
||||
# The env-var form is provided for stateless deployments.
|
||||
#
|
||||
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
|
||||
|
||||
# =============================================================================
|
||||
# Extension Directory / Marketplace
|
||||
# =============================================================================
|
||||
@@ -229,6 +266,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
# These are build-time variables - to change them with the published Docker
|
||||
# image, rebuild it with --build-arg (see README "Default UI locale").
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
|
||||
# Values: never (default) | always | as-needed
|
||||
# NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
|
||||
# =============================================================================
|
||||
# Legacy Build-time Variables (still supported as fallback)
|
||||
# =============================================================================
|
||||
|
||||
+210
@@ -1,5 +1,213 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.6 (2026-06-28)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||
- **Mail**: Option to hide the total message count on folders (#498)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||
- **Auth**: Support MFA login via the structured auth endpoint
|
||||
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||
- **i18n**: Add missing translation keys across 19 locales
|
||||
|
||||
## 1.7.5 (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
|
||||
- **Mail**: Per-account "All Mail" folder selection
|
||||
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
|
||||
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
|
||||
- **Mail**: Collapse-all-threads action in thread-list selection
|
||||
- **Calendar**: Option to disable the calendar
|
||||
- **Composer**: Send-now button on scheduled/delayed messages
|
||||
- **Composer**: Email a contact or group via the in-app composer
|
||||
- **Composer**: Split a pasted address list into recipient chips
|
||||
- **Contacts**: "New address book" creation UI (#415)
|
||||
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
|
||||
- **i18n**: Farsi (fa) locale — complete (2654 strings)
|
||||
- **i18n**: Romanian (ro) locale
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Composer**: Keep HTML signature styling in the editor and on send
|
||||
- **Composer**: Guard Send against double-submit
|
||||
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
|
||||
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
|
||||
- **Mail**: Localize special-folder names by JMAP role (#404)
|
||||
- **Mail**: Block remaining email tracking vectors (#457)
|
||||
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
|
||||
- **Mail**: Fix blank space in plain-text emails
|
||||
- **Mail**: Fix toolbar re-render when opening emails
|
||||
- **Mail**: Truncate long subjects so they don't overlap the timestamp
|
||||
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
|
||||
- **Mail**: Add breathing room between the unread dot and the avatar
|
||||
- **Mail**: Isolate per-account state snapshots from leakage and mutation
|
||||
- **Mail**: Cap filename tokens at the full 200-char limit
|
||||
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
|
||||
- **Filters**: Load mailboxes when opened directly (#485)
|
||||
- **Settings**: Surface server errors on password change and TOTP toggle
|
||||
- **Send now**: Gate the toolbar label and translate `send_now` across locales
|
||||
- **Directory**: Fix fetching display names
|
||||
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
|
||||
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
|
||||
- **i18n**: Add missing translation keys across 19 locales
|
||||
|
||||
## 1.7.4 (2026-06-15)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: New "All Mail" view across folders and accounts
|
||||
- **Mail**: Edit contact directly from the email viewer contact sidebar
|
||||
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
|
||||
- **Calendar**: Agenda plugin sidecar
|
||||
- **Composer**: Email display name support
|
||||
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
|
||||
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
|
||||
- **Files**: JMAP file/folder sharing in the Files app (#408)
|
||||
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
|
||||
- **Auth**: Require re-authentication for device pairing and SSO
|
||||
- **Accounts**: Manage shared/group account settings from the Accounts page
|
||||
- **Setup**: Opt-in telemetry in the web setup wizard
|
||||
- **Mail**: Persist the email detail sidebar state
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
|
||||
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
|
||||
- **Mail**: Stop draft emails from being marked as unread
|
||||
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
|
||||
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
|
||||
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
|
||||
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
|
||||
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
|
||||
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
|
||||
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
|
||||
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
|
||||
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
|
||||
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
|
||||
- **Privacy**: Make telemetry opt-in
|
||||
- **UI**: Fix the context menu being invisible on first right-click after page load
|
||||
- **Admin**: Remove the JMAP status from the admin dashboard
|
||||
- **i18n**: Add missing translation keys across 17 locales
|
||||
|
||||
## 1.7.3 (2026-06-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
|
||||
- **Mail**: Preview composer attachments inline (click to open)
|
||||
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
|
||||
- **Mail**: Read receipts (MDN, RFC 8098)
|
||||
- **Mail**: Editable, layout-preserving quote island when replying
|
||||
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
|
||||
- **Calendar**: Per-viewer colors for shared calendars (#345)
|
||||
- **Filters**: Extended filter rules — attachment field and multi-value conditions
|
||||
- **Settings**: New built-in themes — Aurora Glass and Elastic
|
||||
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
|
||||
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
|
||||
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
|
||||
- **Admin**: Toggle for search-engine indexing (robots)
|
||||
- **Admin**: `passwordHashFile` in `admin.json`
|
||||
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
|
||||
- **PWA**: Configurable install screenshots (per-domain)
|
||||
- **i18n**: Hungarian locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
|
||||
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
|
||||
- **Mail**: Empty Trash for shared and group folders (#387)
|
||||
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
|
||||
- **Mail**: Preserve the HTML signature when sending a quick reply
|
||||
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
|
||||
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
|
||||
- **Mail**: No more 404 console spam for missing sender favicons
|
||||
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
|
||||
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
|
||||
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
|
||||
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
|
||||
- **Themes**: Plugin slot iframes inherit host font and color tokens
|
||||
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
|
||||
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
|
||||
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
|
||||
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
|
||||
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
|
||||
- **UI**: Close pane gaps by centering the resize handle on the seam
|
||||
- **Settings**: Fix section gears permanently hijacking the active tab
|
||||
|
||||
## 1.7.2 (2026-05-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Scheduled send and send delay (#322)
|
||||
- **Mail**: Drag emails out to the file explorer as `.eml`
|
||||
- **Mail**: Import emails from `.zip` archives
|
||||
- **Mail**: "Move to Trash and mark as read" delete action (#323)
|
||||
- **Mail**: Include group inboxes in the unified mailbox view (#328)
|
||||
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
|
||||
- **Mail**: Allow drag-and-drop into shared mailboxes
|
||||
- **Composer**: Ctrl/Cmd+Enter sends the open draft
|
||||
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
|
||||
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
|
||||
- **Settings**: Post-export action (keep / archive / trash)
|
||||
- **Settings**: Template for multi-email `.zip` filenames
|
||||
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
|
||||
- **Admin**: Policy-controlled push relay URL with optional user lock
|
||||
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
|
||||
- **Mail**: Report real upload progress with XHR progress events (#333)
|
||||
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
|
||||
- **Mail**: Match user-avatar treatment on quick reply
|
||||
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
|
||||
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
|
||||
- **Composer**: Stop closing the form when editing any field
|
||||
- **Pro**: Keep the empty viewer pane visible in the split layout
|
||||
- **Pro**: Prevent an empty main pane when reordering tabs across panes
|
||||
- **Mobile**: Collapse focus mail layout to multi-line
|
||||
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
|
||||
- **Calendar**: Align continued multi-week events with the week's left edge
|
||||
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
|
||||
- **Calendar**: Convert `recurrenceRules` to singular in batch create
|
||||
- **Calendar**: Handle malformed event dates (#316)
|
||||
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
|
||||
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
|
||||
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
|
||||
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve inline images when replying (#163)
|
||||
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||
|
||||
## 1.7.0 (2026-05-21)
|
||||
|
||||
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||
@@ -21,6 +229,7 @@
|
||||
- **Pro**: Multi-account contacts and a cross-account file picker
|
||||
- **Pro**: Composer From dropdown grouped by account
|
||||
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
||||
- **Plugins**: Marketplace update flow for installed plugins and themes
|
||||
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||
- **Account**: List and reorder logged-in accounts from settings (#282)
|
||||
@@ -62,6 +271,7 @@
|
||||
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
||||
- **Plugins**: Trust the directory version on marketplace install and update
|
||||
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
||||
|
||||
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||
# Accept-Language header does not match any supported locale. Baked in at
|
||||
# build time because next-intl wires it into client-side routing too.
|
||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
+21
-10
@@ -4,10 +4,15 @@
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
||||
- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
|
||||
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||
- Scheduled send and configurable send delay
|
||||
- Read receipts (MDN, RFC 8098)
|
||||
- Editable, layout-preserving quote island when replying
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
@@ -36,7 +41,7 @@
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
@@ -52,7 +57,7 @@
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
@@ -60,19 +65,20 @@
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
@@ -85,6 +91,7 @@
|
||||
|
||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
@@ -98,7 +105,7 @@
|
||||
|
||||
## Internationalization
|
||||
|
||||
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
@@ -110,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
- Configurable signature position (above or below quoted text)
|
||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
@@ -117,8 +125,11 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||
- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
|
||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
|
||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||
@@ -126,10 +137,10 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
||||
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – version, platform, bucketed account counts, feature toggles only
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||
- Demo mode with fixture data – no mail server required
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
@@ -81,12 +81,12 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
||||
|
||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
|
||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# Per-domain overrides (optional). When the webmail is served on multiple
|
||||
# hostnames, each host can override any subset of the branding fields above.
|
||||
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
|
||||
# match any subdomain. Unset fields fall back to the global values.
|
||||
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step-up re-auth for device pairing: the QR generator sent the user here
|
||||
// via prompt=login. Don't create a login session — just confirm the fresh
|
||||
// auth (sets the short-lived pairing proof cookie) and bounce back to the
|
||||
// Security settings, where the QR generation auto-resumes.
|
||||
let pairReauthResume = false;
|
||||
try {
|
||||
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
if (pairReauthResume && state) {
|
||||
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/auth/reauth/sso/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ code, state }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("token_exchange_failed");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem("pair_reauth_done", "1");
|
||||
// Land back on the Security tab (readPersistedTab reads this key).
|
||||
sessionStorage.setItem("settings-deep-link-tab", "security");
|
||||
} catch { /* ignore */ }
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
|
||||
} catch {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const savedState = sessionStorage.getItem("oauth_state");
|
||||
|
||||
if (savedState) {
|
||||
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
router.push(redirectTo);
|
||||
router.push(toRouterPath(redirectTo));
|
||||
} else {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
@@ -96,7 +132,7 @@ function OAuthCallbackInner() {
|
||||
// the refresh-token cookie write for the same reason.
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/sso/complete", {
|
||||
const res = await apiFetch("/api/auth/sso/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
router.push(redirectTo);
|
||||
router.push(toRouterPath(redirectTo));
|
||||
} else {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
||||
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
|
||||
>
|
||||
{t("oauth_error.back_to_login")}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -60,6 +61,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
@@ -96,7 +98,11 @@ export default function CalendarPage() {
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
@@ -175,10 +181,13 @@ export default function CalendarPage() {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
} else if (client && !calendarEnabled) {
|
||||
// Calendar disabled by admin policy - send the user back to mail.
|
||||
router.push("/");
|
||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -1030,10 +1039,47 @@ export default function CalendarPage() {
|
||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||
})();
|
||||
|
||||
// Apply each shared calendar's local color override (per-viewer recolor,
|
||||
// #345). The override replaces the calendar's color and wins over per-event
|
||||
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
|
||||
// calendars are passed through untouched.
|
||||
const displayCalendars = useMemo(() => {
|
||||
return calendars.map((cal) => {
|
||||
if (!cal.isShared) return cal;
|
||||
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
if (!override) return cal;
|
||||
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||
});
|
||||
}, [calendars, sharedCalendarColors]);
|
||||
|
||||
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||
// once per calendar (guarded by the presence of an existing key), and the
|
||||
// user can still overwrite it from the sidebar.
|
||||
useEffect(() => {
|
||||
const shared = calendars.filter((c) => c.isShared);
|
||||
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
|
||||
if (missing.length === 0) return;
|
||||
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||
// overrides so the picks stay distinct from what's already on screen.
|
||||
const used = new Set<string>();
|
||||
for (const c of calendars) {
|
||||
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
|
||||
}
|
||||
for (const color of Object.values(sharedCalendarColors)) {
|
||||
if (color) used.add(color.toLowerCase());
|
||||
}
|
||||
for (const cal of missing) {
|
||||
const color = pickUnusedCalendarColor(used);
|
||||
used.add(color.toLowerCase());
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
}
|
||||
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||
|
||||
const allCalendars = useMemo(() => {
|
||||
if (!showBirthdayCalendar) return calendars;
|
||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
if (!showBirthdayCalendar) return displayCalendars;
|
||||
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const filtered = events.filter((e) => {
|
||||
@@ -1123,6 +1169,7 @@ export default function CalendarPage() {
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (!calendarEnabled) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
|
||||
const renderView = () => {
|
||||
@@ -1219,7 +1266,7 @@ export default function CalendarPage() {
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
showCompleted={taskStore.showCompleted}
|
||||
@@ -1317,8 +1364,21 @@ export default function CalendarPage() {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
return;
|
||||
}
|
||||
// Shared calendars: recolor locally only (the viewer usually
|
||||
// can't write the owner's calendar, and it'd recolor it for
|
||||
// everyone). Personal calendars write through to the server.
|
||||
const cal = allCalendars.find((c) => c.id === calendarId);
|
||||
if (cal?.isShared) {
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onResetColor={(cal) => {
|
||||
// Drop the local override; the auto-assign effect picks a
|
||||
// fresh unused color (so it never reverts to a collision).
|
||||
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||
}}
|
||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||
onCreateEvent={(cal: Calendar) => {
|
||||
setDefaultCalendarIdForCreate(cal.id);
|
||||
@@ -1389,7 +1449,7 @@ export default function CalendarPage() {
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
isMobile={isMobile}
|
||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
@@ -1419,7 +1479,7 @@ export default function CalendarPage() {
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
@@ -1442,7 +1502,7 @@ export default function CalendarPage() {
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||
@@ -1529,7 +1589,7 @@ export default function CalendarPage() {
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
||||
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||
anchorRect={detailAnchorRect}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -1549,7 +1609,7 @@ export default function CalendarPage() {
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
@@ -1566,7 +1626,7 @@ export default function CalendarPage() {
|
||||
|
||||
{showImportModal && client && (
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
client={client}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
|
||||
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -82,6 +84,7 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
createAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
@@ -93,6 +96,7 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||
@@ -173,12 +177,13 @@ export default function ContactsPage() {
|
||||
const addEmail = searchParams.get('addEmail');
|
||||
const addName = searchParams.get('addName');
|
||||
const from = searchParams.get('from');
|
||||
const viewParam = searchParams.get('view');
|
||||
if (!contactId && !addEmail && !from) return;
|
||||
intentAppliedRef.current = true;
|
||||
if (from === 'email') setReturnToEmail(true);
|
||||
if (contactId) {
|
||||
setSelectedContact(contactId);
|
||||
setView('detail');
|
||||
setView(viewParam === 'edit' ? 'edit' : 'detail');
|
||||
} else if (addEmail) {
|
||||
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||
setSelectedContact(null);
|
||||
@@ -297,6 +302,34 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||
|
||||
// Refresh address books (and contacts) after a structural change, staying
|
||||
// multi-account aware so a freshly created book lands in the sidebar.
|
||||
const refreshAddressBooks = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
const activeId = useAuthStore.getState().activeAccountId;
|
||||
if (activeId) {
|
||||
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||
await fetchAllAccountsAddressBooks(accountClients, activeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await useContactStore.getState().fetchAddressBooks(client);
|
||||
}, [client, multiAccountEnabled, accountClients]);
|
||||
|
||||
const handleCreateAddressBook = useCallback(async (name: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await createAddressBook(client, name);
|
||||
await refreshAddressBooks();
|
||||
toast.success(t("address_books.created"));
|
||||
setCreatingAddressBook(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create address book:', error);
|
||||
toast.error(t("address_books.create_failed"));
|
||||
}
|
||||
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
@@ -460,6 +493,51 @@ export default function ContactsPage() {
|
||||
setView("group-edit");
|
||||
}, []);
|
||||
|
||||
// Open the in-app composer in the current session rather than routing through
|
||||
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
|
||||
// (which may open a different mail app), and the mailto protocol round-trip
|
||||
// reloads the app - dropping the in-memory per-account JMAP clients of a
|
||||
// multi-account session, which reads as a logout. Stashing the recipients and
|
||||
// doing a client-side router.push keeps the session and the active account
|
||||
// intact; the main route consumes the pending compose and opens the composer
|
||||
// (see consumePendingMailto in page.tsx).
|
||||
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
|
||||
savePendingMailto({
|
||||
to: field === "to" ? recipients : [],
|
||||
cc: field === "cc" ? recipients : [],
|
||||
bcc: field === "bcc" ? recipients : [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||
// Format each member as "Name <email>" so the composer keeps the display
|
||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
||||
// email, case-insensitively; members without an email are skipped.
|
||||
const seen = new Set<string>();
|
||||
const recipients: string[] = [];
|
||||
for (const member of getGroupMembers(groupId)) {
|
||||
const email = getContactPrimaryEmail(member).trim();
|
||||
const key = email.toLowerCase();
|
||||
if (!email || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
||||
}
|
||||
if (recipients.length === 0) {
|
||||
toast.error(t("groups.no_member_emails"));
|
||||
return;
|
||||
}
|
||||
openComposeInApp(recipients, field);
|
||||
}, [getGroupMembers, t, openComposeInApp]);
|
||||
|
||||
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||
const email = getContactPrimaryEmail(contact).trim();
|
||||
if (!email) return;
|
||||
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
|
||||
}, [openComposeInApp]);
|
||||
|
||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
@@ -624,6 +702,7 @@ export default function ContactsPage() {
|
||||
onEdit={handleEditGroup}
|
||||
onDelete={handleDeleteGroup}
|
||||
onRemoveMember={handleRemoveGroupMember}
|
||||
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||
isMobile={isMobile}
|
||||
onSelectMember={(id) => {
|
||||
setSelectedContact(id);
|
||||
@@ -701,6 +780,11 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCompose={
|
||||
selectedContact
|
||||
? () => handleComposeContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
@@ -803,9 +887,11 @@ export default function ContactsPage() {
|
||||
onSelectCategory={handleSelectCategory}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onCreateContact={handleCreateNew}
|
||||
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onComposeGroup={handleComposeGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
@@ -951,6 +1037,15 @@ export default function ContactsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{creatingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("address_books.create")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setCreatingAddressBook(false)}
|
||||
onConfirm={handleCreateAddressBook}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
|
||||
@@ -22,12 +22,13 @@ import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
@@ -48,9 +49,11 @@ export default function FilesPage() {
|
||||
supportsFiles,
|
||||
selectedResources,
|
||||
uploadProgress,
|
||||
migrationProgress,
|
||||
clipboard,
|
||||
initClient,
|
||||
checkSupport,
|
||||
migrateLegacyFlatNodes,
|
||||
navigate,
|
||||
navigateByPath,
|
||||
refresh,
|
||||
@@ -86,6 +89,7 @@ export default function FilesPage() {
|
||||
cancelUpload,
|
||||
undoLastAction,
|
||||
lastAction,
|
||||
shareResource,
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -160,13 +164,16 @@ export default function FilesPage() {
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
useEffect(() => {
|
||||
if (storeClient && supportsFiles === null) {
|
||||
checkSupport().then((supported) => {
|
||||
checkSupport().then(async (supported) => {
|
||||
if (supported) {
|
||||
// Upgrade any files created by older builds (flat path-encoded names)
|
||||
// into the real FileNode hierarchy before the first listing.
|
||||
await migrateLegacyFlatNodes();
|
||||
navigate(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
|
||||
|
||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||
@@ -396,6 +403,14 @@ export default function FilesPage() {
|
||||
|
||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||
|
||||
// Sharing: the browsing client (store-attached) drives the principal picker
|
||||
// and share mutations. supportsPrincipals() gates the whole Share affordance.
|
||||
const sharingEnabled = !!storeClient?.supportsPrincipals();
|
||||
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
|
||||
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
|
||||
await shareResource(id, principalId, rights);
|
||||
}, [shareResource]);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
@@ -535,6 +550,10 @@ export default function FilesPage() {
|
||||
onSelectAccount={handleSelectAccount}
|
||||
accountPickerMode={isAccountPicker}
|
||||
accountLabel={currentAccountLabel}
|
||||
client={storeClient}
|
||||
ownAccountId={filesAccountId}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -573,6 +592,32 @@ export default function FilesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Legacy file migration progress (issue #379) */}
|
||||
{migrationProgress && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("migration_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style={{ width: migrationProgress.total > 0
|
||||
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
||||
{migrationProgress.current} / {migrationProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
<PWAInstallPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
|
||||
@@ -11,10 +11,10 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||
@@ -279,7 +279,7 @@ export default function LoginPage() {
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
router.push(toRouterPath(redirectTo));
|
||||
}
|
||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
@@ -329,16 +329,27 @@ export default function LoginPage() {
|
||||
if (!oauthEnabled || !serverUrl) return;
|
||||
setOauthDiscoveryDone(false);
|
||||
setOauthMetadata(null);
|
||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||
const controller = new AbortController();
|
||||
// Discover via our own origin rather than fetching the IdP's /.well-known/*
|
||||
// documents directly from the browser. A direct cross-origin discovery
|
||||
// fetch is subject to CORS, and providers like Authentik serve those
|
||||
// documents without Access-Control-Allow-Origin, so the browser blocks the
|
||||
// response and login breaks (issue #382). The proxy runs discovery server
|
||||
// side where CORS does not apply.
|
||||
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
|
||||
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
|
||||
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
|
||||
.then((metadata) => {
|
||||
setOauthMetadata(metadata);
|
||||
setOauthDiscoveryDone(true);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
if (err?.name === "AbortError") return;
|
||||
setOauthMetadata(null);
|
||||
setOauthDiscoveryDone(true);
|
||||
});
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||
return () => controller.abort();
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
|
||||
|
||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||
const ssoError = searchParams.get("sso_error");
|
||||
@@ -644,7 +655,7 @@ export default function LoginPage() {
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
router.push(toRouterPath(redirectTo));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -722,7 +733,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -872,7 +883,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
/>
|
||||
|
||||
+674
-191
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
import MailPage from "@/app/(main)/[locale]/page";
|
||||
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||
@@ -172,7 +173,7 @@ export default function ProHome() {
|
||||
// enabled it. If either precondition stops holding, hand the user back
|
||||
// to the standard shell.
|
||||
if (isMobile || isTablet || !proInterface) {
|
||||
window.location.replace("/");
|
||||
window.location.replace(`${getPathPrefix()}/`);
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Tags,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
@@ -33,6 +32,8 @@ import {
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
SwatchBook,
|
||||
Download,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -59,8 +60,8 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
@@ -71,6 +72,7 @@ import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
@@ -90,6 +92,7 @@ type Tab =
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
@@ -97,7 +100,6 @@ type Tab =
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
@@ -126,6 +128,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
@@ -133,7 +136,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
@@ -141,7 +143,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
debug: Bug,
|
||||
};
|
||||
@@ -177,6 +179,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.appearance.hide_account_switcher',
|
||||
'settings.appearance.show_rail_account_list',
|
||||
'settings.appearance.unified_mailbox',
|
||||
'settings.appearance.all_mail',
|
||||
'settings.appearance.colorful_sidebar_icons',
|
||||
'settings.email_behavior.mail_layout',
|
||||
],
|
||||
@@ -193,15 +196,16 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.hover_actions',
|
||||
'settings.email_behavior.permanently_delete_junk',
|
||||
'settings.email_behavior.show_preview',
|
||||
'settings.email_behavior.plain_text_mode',
|
||||
],
|
||||
composing: [
|
||||
'settings.email_behavior.attachment_reminder',
|
||||
'settings.email_behavior.auto_select_reply_identity',
|
||||
'settings.email_behavior.plain_text_mode',
|
||||
'settings.email_behavior.default_mail_program',
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
@@ -209,7 +213,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
folders: ['settings.folders'],
|
||||
keywords: ['settings.keywords'],
|
||||
security: ['settings.security'],
|
||||
encryption: ['smime'],
|
||||
content_senders: [
|
||||
'settings.email_behavior.always_light_mode',
|
||||
'settings.email_behavior.external_content',
|
||||
@@ -236,6 +239,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
@@ -243,7 +247,6 @@ const tabKeywords: Record<Tab, string> = {
|
||||
folders: 'mailbox subscribe',
|
||||
keywords: 'tags labels colors',
|
||||
security: 'password 2fa two-factor passkey app password mfa',
|
||||
encryption: 's/mime smime certificate pgp gpg',
|
||||
content_senders: 'block sender remote images privacy tracking',
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
@@ -329,6 +332,14 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
try {
|
||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||
// Used only as the initial tab and intentionally NOT written to
|
||||
// 'settings-active-tab', so a gear click never becomes the persisted
|
||||
// default that the regular Settings button lands on. Cleared on mount.
|
||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||
if (deepLink) {
|
||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
||||
}
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
if (saved in LEGACY_TAB_MAP) {
|
||||
@@ -354,6 +365,11 @@ export default function SettingsPage() {
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||
// open, never the persisted default for future Settings-button clicks.
|
||||
useEffect(() => {
|
||||
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||
@@ -365,6 +381,12 @@ export default function SettingsPage() {
|
||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
|
||||
// When set, the settings panel is scoped to a shared/group account: a reduced
|
||||
// tab list and a "Managing: <name>" header. null = the user's own account.
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
|
||||
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
|
||||
|
||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||
// (individual settings) per tab. Sub-results come from translation entries
|
||||
// that have a `label`/`title` field, plus dynamic content (installed
|
||||
@@ -468,6 +490,10 @@ export default function SettingsPage() {
|
||||
return () => window.removeEventListener('settings-tab-change', handler);
|
||||
}, []);
|
||||
|
||||
// Leaving the settings panel drops any shared-account scope so it never
|
||||
// leaks into the next visit or another session.
|
||||
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
@@ -576,10 +602,12 @@ export default function SettingsPage() {
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
@@ -589,28 +617,42 @@ export default function SettingsPage() {
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
|
||||
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
|
||||
// is hardwired to the active account). Gated on both the per-account
|
||||
// capability and the session-level support/feature flags.
|
||||
const scopedTabIds: Tab[] = managedAccount
|
||||
? ([
|
||||
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
|
||||
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
|
||||
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
|
||||
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
|
||||
].filter(Boolean) as Tab[])
|
||||
: [];
|
||||
const visibleTabs = managedAccountId
|
||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
|
||||
: tabs;
|
||||
|
||||
// Group tabs by category
|
||||
const groupedTabs = tabGroupOrder
|
||||
.map((group) => ({
|
||||
group,
|
||||
label: t(`tab_groups.${group}`),
|
||||
items: tabs.filter((tab) => tab.group === group),
|
||||
items: visibleTabs.filter((tab) => tab.group === group),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
@@ -638,9 +680,13 @@ export default function SettingsPage() {
|
||||
.filter((g) => g.items.length > 0)
|
||||
: groupedTabs;
|
||||
|
||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||
// otherwise the usual 'appearance' default.
|
||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible
|
||||
? activeTab
|
||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
setActiveTab(tabId);
|
||||
@@ -655,10 +701,26 @@ export default function SettingsPage() {
|
||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||
};
|
||||
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
|
||||
const renderTabContent = () => (
|
||||
<>
|
||||
{managedAccountId && managedAccount && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearManagedAccount();
|
||||
handleTabSelect('account');
|
||||
}}
|
||||
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||
<span className="ml-auto text-sm font-medium truncate">
|
||||
{t('scoped.managing', { name: managedAccount.name })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||
@@ -666,6 +728,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
@@ -673,10 +736,17 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'calendar' && (
|
||||
managedAccountId
|
||||
? <CalendarManagementSettings />
|
||||
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
|
||||
)}
|
||||
{effectiveActiveTab === 'contacts' && (
|
||||
managedAccountId
|
||||
? <AddressBookManagementSettings />
|
||||
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||
)}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
@@ -925,7 +995,7 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
effectiveActiveTab === tab.id
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
const lastEmittedRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
if (value === lastEmittedRef.current) return;
|
||||
setDrafts(value.map(entryToDraft))
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||
lastEmittedRef.current = entries;
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ export function AuthTab() {
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import {
|
||||
BRANDING_OVERRIDE_KEYS,
|
||||
parseDomainBranding,
|
||||
type BrandingOverrideKey,
|
||||
type DomainBrandingEntry,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
interface ConfigEntry {
|
||||
value?: unknown;
|
||||
@@ -16,28 +22,67 @@ const IMAGE_FIELDS = [
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||
] as const;
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||
] as const;
|
||||
|
||||
const PWA_COLOR_FIELDS = [
|
||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||
] as const;
|
||||
|
||||
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
|
||||
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
// Tighter rule for uploads: wildcards can only point to externally-hosted
|
||||
// URLs, since we'd have no concrete subdomain to serve a file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||
() => parseDomainBranding(config['domainBranding']?.value),
|
||||
[config],
|
||||
);
|
||||
|
||||
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
|
||||
useEffect(() => {
|
||||
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
}
|
||||
}, [domainEntries, selectedHost]);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
@@ -45,29 +90,81 @@ export function BrandingTab() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function selectedEntry(): DomainBrandingEntry | null {
|
||||
if (!selectedHost) return null;
|
||||
return domainEntries.find(e => e.host === selectedHost) ?? null;
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key] as string;
|
||||
if (key in edits) return edits[key];
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
|
||||
}
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
function isOverriddenInScope(key: string): boolean {
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
const v = entry?.[key as BrandingOverrideKey];
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}
|
||||
return config[key]?.source === 'admin';
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
|
||||
if (!selectedHost) return domainEntries;
|
||||
const next = domainEntries.slice();
|
||||
const idx = next.findIndex(e => e.host === selectedHost);
|
||||
const base: DomainBrandingEntry =
|
||||
idx === -1 ? { host: selectedHost } : { ...next[idx] };
|
||||
const writable = base as unknown as Record<string, string | undefined>;
|
||||
for (const [key, value] of Object.entries(merge)) {
|
||||
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
writable[key] = value;
|
||||
} else {
|
||||
delete writable[key];
|
||||
}
|
||||
}
|
||||
if (idx === -1) next.push(base);
|
||||
else next[idx] = base;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const payload = selectedHost
|
||||
? { domainBranding: buildUpdatedDomainBranding(edits) }
|
||||
: edits;
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: selectedHost
|
||||
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
|
||||
: 'Branding updated. Changes visible on next page load.',
|
||||
});
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
@@ -78,12 +175,20 @@ export function BrandingTab() {
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
if (selectedHost) formData.append('host', selectedHost);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
@@ -98,10 +203,9 @@ export function BrandingTab() {
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
}));
|
||||
// Refresh from server so domainBranding entries reflect the upload.
|
||||
await fetchConfig();
|
||||
void data;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
@@ -112,10 +216,13 @@ export function BrandingTab() {
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const body: { slot: string; host?: string } = { slot };
|
||||
if (selectedHost) body.host = selectedHost;
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -133,6 +240,25 @@ export function BrandingTab() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
if (selectedHost) {
|
||||
// Domain scope: drop the field from the entry and PATCH the array.
|
||||
const updated = buildUpdatedDomainBranding({ [key]: '' });
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: updated }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Default scope: revert via DELETE /api/admin/config
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -148,12 +274,71 @@ export function BrandingTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
async function handleAddDomain() {
|
||||
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
|
||||
if (!host) {
|
||||
setNewHostError('Enter a hostname');
|
||||
return;
|
||||
}
|
||||
if (!HOST_RE.test(host)) {
|
||||
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
|
||||
return;
|
||||
}
|
||||
if (domainEntries.some(e => e.host === host)) {
|
||||
setNewHostError('A branding entry for this host already exists');
|
||||
return;
|
||||
}
|
||||
setNewHostError(null);
|
||||
|
||||
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewHostInput('');
|
||||
setAddingHost(false);
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setNewHostError(data.error || 'Failed to add domain');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDomain() {
|
||||
if (!selectedHost) return;
|
||||
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
|
||||
return;
|
||||
}
|
||||
const next = domainEntries.filter(e => e.host !== selectedHost);
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleScopeChange(host: string | null) {
|
||||
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
@@ -178,6 +363,102 @@ export function BrandingTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope picker */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Scope</h2>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(null)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{domainEntries.map(entry => (
|
||||
<button
|
||||
key={entry.host}
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(entry.host)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === entry.host
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{entry.host}
|
||||
</button>
|
||||
))}
|
||||
{!addingHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(true); setNewHostError(null); }}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add domain
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addingHost && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newHostInput}
|
||||
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
|
||||
placeholder="mail.example.com or *.example.com"
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddDomain}
|
||||
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||
Unset fields fall back to the Default values.
|
||||
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDomain}
|
||||
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Remove domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
@@ -195,9 +476,9 @@ export function BrandingTab() {
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,7 +487,7 @@ export function BrandingTab() {
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
@@ -222,9 +503,9 @@ export function BrandingTab() {
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
@@ -237,7 +518,7 @@ export function BrandingTab() {
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -249,7 +530,7 @@ export function BrandingTab() {
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
@@ -262,6 +543,146 @@ export function BrandingTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{PWA_IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{PWA_TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
@@ -271,8 +692,10 @@ export function BrandingTab() {
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
@@ -283,7 +706,7 @@ export function BrandingTab() {
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -32,7 +32,6 @@ export function DashboardTab() {
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
@@ -82,15 +81,6 @@ export function DashboardTab() {
|
||||
}
|
||||
}
|
||||
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
const jmapRes = await apiFetch('/api/config');
|
||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||
} catch {
|
||||
setJmapHealth('error');
|
||||
}
|
||||
}
|
||||
|
||||
const w: string[] = [];
|
||||
if (adminConfigRes.ok) {
|
||||
const sources = await adminConfigRes.json();
|
||||
@@ -128,16 +118,6 @@ export function DashboardTab() {
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Connection">
|
||||
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||
}`} />
|
||||
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last Login">
|
||||
<span className="text-sm text-foreground">
|
||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
|
||||
@@ -13,6 +13,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
|
||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||
@@ -21,6 +22,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
@@ -28,7 +33,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
@@ -74,6 +79,18 @@ export function PolicyTab() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setPushRelayUrl(value: string) {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function togglePushRelayLocked() {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleLocked(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
@@ -183,6 +200,34 @@ export function PolicyTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={policy.pushRelayUrl ?? ''}
|
||||
onChange={(e) => setPushRelayUrl(e.target.value)}
|
||||
placeholder="https://notifications.relay.example.com"
|
||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!policy.pushRelayUrlLocked}
|
||||
onChange={togglePushRelayLocked}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
|
||||
@@ -125,6 +125,7 @@ export function SettingsTab() {
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
|
||||
@@ -118,9 +118,10 @@ export function TelemetryTab() {
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
||||
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
|
||||
running, on what platforms, and which features they use. It's <strong>off by
|
||||
default</strong>; one click below enables it and helps us make the product better. No
|
||||
email addresses, no hostnames, no IPs are sent.{' '}
|
||||
<a
|
||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||
target="_blank"
|
||||
@@ -138,8 +139,8 @@ export function TelemetryTab() {
|
||||
<div className="font-medium">Status</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
|
||||
{status.consent === 'off' && 'Heartbeats are off (default).'}
|
||||
{envOverridden && (
|
||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||
];
|
||||
// Derive from the single source of truth so newly added built-in themes show
|
||||
// up here automatically (was previously a hardcoded subset — see #496).
|
||||
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||
|
||||
interface ThemeEntry {
|
||||
id: string;
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
+29
-6
@@ -1,12 +1,25 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||
// next-intl's getLocale() returns the default locale here - emitting
|
||||
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
|
||||
// "translate this page". Recover the active locale from the request pathname
|
||||
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
|
||||
// Accept-Language) when the path carries no locale segment.
|
||||
async function resolveRequestLocale(): Promise<string> {
|
||||
const pathname = (await headers()).get("x-pathname") || "";
|
||||
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
|
||||
return seg ?? (await getLocale());
|
||||
}
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
@@ -26,10 +39,21 @@ export const viewport: Viewport = {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// Localize the <head> description to match the UI language; a hardcoded
|
||||
// English description is another signal that makes Chrome offer to
|
||||
// "translate this page". Resolve the locale from the request path, since this
|
||||
// layout is above the [locale] segment (see resolveRequestLocale).
|
||||
const locale = await resolveRequestLocale();
|
||||
const t = await getTranslations({ locale });
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
description: t("meta_description"),
|
||||
// A private webmail should not be indexed by search engines. This is opt-in
|
||||
// via Settings -> General; the default (false) emits noindex/nofollow.
|
||||
robots: configManager.get<boolean>("searchEngineIndexing", false)
|
||||
? { index: true, follow: true }
|
||||
: { index: false, follow: false },
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
@@ -38,7 +62,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
icons: { icon: faviconUrl },
|
||||
icons: { icon: withBasePath(faviconUrl) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +71,7 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const locale = await resolveRequestLocale();
|
||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
@@ -91,7 +115,6 @@ export default async function RootLayout({
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -38,6 +38,7 @@ interface WizardConfig {
|
||||
// Security
|
||||
sessionSecret: string;
|
||||
settingsSyncEnabled: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
// Logging
|
||||
logFormat: 'text' | 'json';
|
||||
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
||||
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
|
||||
oauthIssuerUrl: '',
|
||||
sessionSecret: '',
|
||||
settingsSyncEnabled: true,
|
||||
telemetryEnabled: false,
|
||||
logFormat: 'text',
|
||||
logLevel: 'info',
|
||||
faviconUrl: '',
|
||||
@@ -258,7 +260,7 @@ export default function SetupWizardPage() {
|
||||
// edge cases that swallow client-side replaces after the
|
||||
// setupComplete flag flips.
|
||||
setTimeout(() => {
|
||||
window.location.assign('/admin/login');
|
||||
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||
}, 1500);
|
||||
}}
|
||||
/>
|
||||
@@ -340,13 +342,13 @@ function CompletedScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -422,13 +424,13 @@ function AlreadyConfiguredScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const values: Partial<WizardConfig> = {
|
||||
// telemetryConsent is persisted to the telemetry state file by the API,
|
||||
// not to admin config - see app/api/setup/step/route.ts.
|
||||
const values: Record<string, unknown> = {
|
||||
settingsSyncEnabled: config.settingsSyncEnabled,
|
||||
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
|
||||
};
|
||||
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
||||
await onNext('security', values);
|
||||
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
hint="Stores user preferences server-side, encrypted with the session secret."
|
||||
disabled={!config.sessionSecret}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<Toggle
|
||||
checked={config.telemetryEnabled}
|
||||
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
|
||||
label="Send anonymous usage stats to help improve Bulwark"
|
||||
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
|
||||
/>
|
||||
</div>
|
||||
<Footer>
|
||||
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={submitting}>
|
||||
@@ -1329,7 +1343,7 @@ function BrandingAsset({
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
||||
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||
)}
|
||||
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
|
||||
: 'Off'
|
||||
}
|
||||
/>
|
||||
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
|
||||
</SummaryGroup>
|
||||
|
||||
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '../globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||
// so the sandbox never fetches same-origin assets itself.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
||||
>
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||
// approval) is enforced host-side before this route is ever framed; the page
|
||||
// itself carries no extra privilege.
|
||||
//
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PrivilegedPluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
+160
-32
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
parseDomainBranding,
|
||||
type DomainBrandingEntry,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -21,26 +26,99 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
type UploadSlot = BrandingOverrideKey;
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
const VALID_SLOTS = new Set<UploadSlot>([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
|
||||
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
|
||||
|
||||
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
|
||||
// because we'd need a real subdomain to serve the file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip directory traversal, keep only safe chars
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function normalizeHost(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Filename used to store a per-host uploaded asset. */
|
||||
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
|
||||
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
|
||||
}
|
||||
|
||||
/** True if the file belongs to the given host+slot (any extension). */
|
||||
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
|
||||
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
|
||||
return filename.startsWith(prefix);
|
||||
}
|
||||
|
||||
/** Merge a per-host update into the existing domainBranding array. */
|
||||
function mergeDomainEntry(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
patch: Partial<DomainBrandingEntry>,
|
||||
): DomainBrandingEntry[] {
|
||||
const next = current.slice();
|
||||
const idx = next.findIndex(e => e.host === host);
|
||||
if (idx === -1) {
|
||||
next.push({ host, ...patch });
|
||||
} else {
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove keys from a host's entry. If the entry has nothing left besides
|
||||
* `host`, drop it entirely. */
|
||||
function clearDomainKeys(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
keys: BrandingOverrideKey[],
|
||||
): DomainBrandingEntry[] {
|
||||
const idx = current.findIndex(e => e.host === host);
|
||||
if (idx === -1) return current;
|
||||
const entry = { ...current[idx] };
|
||||
for (const key of keys) delete (entry as Record<string, unknown>)[key];
|
||||
const next = current.slice();
|
||||
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
|
||||
next.splice(idx, 1);
|
||||
} else {
|
||||
next[idx] = entry;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||
* - host (optional): when set, the upload is stored against the
|
||||
* per-domain entry for that hostname instead of the global default.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -51,15 +129,24 @@ export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const slot = formData.get('slot') as string | null;
|
||||
const rawHost = (formData.get('host') as string | null) ?? '';
|
||||
|
||||
if (!file || !slot) {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
@@ -71,34 +158,51 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine extension from mime type
|
||||
const extMap: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||
const safeName = host
|
||||
? domainAssetName(host, slot as BrandingOverrideKey, ext)
|
||||
: sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
// Strip any prior asset for the same slot but a different extension so
|
||||
// the directory doesn't accumulate orphan files on re-upload.
|
||||
const dir = getBrandingDir();
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (f === safeName) continue;
|
||||
const isSame = host
|
||||
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
|
||||
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
|
||||
if (isSame) {
|
||||
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Update config to point to the served URL
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
}
|
||||
|
||||
await auditLog('branding_upload', {
|
||||
slot,
|
||||
host: host || undefined,
|
||||
filename: safeName,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
}, ip);
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
@@ -110,7 +214,11 @@ export async function POST(request: NextRequest) {
|
||||
/**
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
* Expects JSON body: { slot: string, host?: string }
|
||||
*
|
||||
* When `host` is provided, only the per-domain asset for that host+slot is
|
||||
* removed (and the override in `domainBranding[host][slot]` is cleared).
|
||||
* Otherwise the global asset and config override are removed.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
@@ -118,28 +226,48 @@ export async function DELETE(request: NextRequest) {
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slot } = await request.json();
|
||||
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
|
||||
const slot = body.slot;
|
||||
const rawHost = body.host ?? '';
|
||||
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find and remove matching files for this slot
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getBrandingDir();
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
if (host) {
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
|
||||
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ext of POSSIBLE_EXTS) {
|
||||
const filePath = path.join(dir, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the config override so it falls back to default/env
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.removeAdminOverride(slot);
|
||||
}
|
||||
|
||||
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
@@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) {
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Normalize domainBranding: drop entries with an invalid/missing host or
|
||||
// duplicate hosts before persisting. Each entry's branding field strings
|
||||
// are passed through unchanged (URL/string content is the operator's
|
||||
// responsibility, same as the flat branding fields).
|
||||
if ('domainBranding' in updates) {
|
||||
const incoming = updates.domainBranding;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseDomainBranding(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.domainBranding = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
type: manifest.type as string,
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
@@ -192,6 +193,9 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
|
||||
/**
|
||||
* Same-origin OAuth metadata (discovery) proxy.
|
||||
*
|
||||
* The login page needs the authorization_endpoint to build the PKCE authorize
|
||||
* URL in the browser. Discovering it directly from the browser means a
|
||||
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
|
||||
* to CORS: providers like Authentik serve those documents without an
|
||||
* Access-Control-Allow-Origin header, so the browser blocks the response and
|
||||
* discovery fails (issue #382). Performing discovery here - server to server,
|
||||
* where CORS does not apply - and handing the result back as a same-origin
|
||||
* response sidesteps the problem entirely.
|
||||
*
|
||||
* The discovery URL is resolved from admin config (via server_id), never from
|
||||
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
|
||||
* in the discovered document are still gated by the SSRF validator inside
|
||||
* discoverOAuth. The returned fields are public well-known metadata.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
await configManager.ensureLoaded();
|
||||
const serverId = request.nextUrl.searchParams.get('server_id');
|
||||
|
||||
let discoveryUrl: string;
|
||||
try {
|
||||
({ discoveryUrl } = getRequiredConfig(serverId));
|
||||
} catch {
|
||||
// OAuth not configured for this server - surface as "no metadata" rather
|
||||
// than a 500 so the login page just hides the SSO button.
|
||||
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await getMetadata(serverId);
|
||||
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
|
||||
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(metadata, {
|
||||
// Mirror the in-process discovery cache TTL so repeated login-page loads
|
||||
// hit the CDN/browser cache instead of re-running discovery.
|
||||
headers: { 'Cache-Control': 'private, max-age=600' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('OAuth metadata discovery error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { createPairing } from '@/lib/auth/pairing-store';
|
||||
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
// Desktop side of the cross-device QR login. The caller must be a signed-in
|
||||
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
|
||||
// refresh that token to (a) prove the session is live and (b) obtain a fresh
|
||||
// access token to hand the phone, then stash the bundle under a one-time
|
||||
// pairing code. The desktop renders the returned code as a QR; the phone
|
||||
// redeems it at /api/auth/pair/redeem.
|
||||
//
|
||||
// Token sharing note: the phone receives the SAME refresh token as the desktop.
|
||||
// That is correct for OAuth servers (such as Stalwart in its default config)
|
||||
// that do not rotate refresh tokens on use. If the server rotates refresh
|
||||
// tokens, the two devices would fight over the latest token — such deployments
|
||||
// should disable rotation for this client or use a token-exchange grant.
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
// Step-up gate: minting a pairing code grants new-device access, so it
|
||||
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
|
||||
// The client turns this 401 into a re-auth redirect, then retries.
|
||||
if (!(await hasValidPairReauth())) {
|
||||
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const slot =
|
||||
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
|
||||
? body.slot
|
||||
: 0;
|
||||
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
|
||||
}
|
||||
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
// Stale session — clear the dead cookie so the user is prompted to log
|
||||
// back in, mirroring the token route's behaviour.
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
if (!tokens.access_token) {
|
||||
logger.error('Pair create: refresh response missing access_token');
|
||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||
}
|
||||
|
||||
// If the server rotated the refresh token, persist the new one back to the
|
||||
// desktop's cookie so this very session keeps working. The phone will get
|
||||
// the same (new) token below.
|
||||
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
const { clientId, serverUrl } = getRequiredConfig(serverId);
|
||||
|
||||
const { code, expiresIn } = createPairing({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: effectiveRefreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
serverUrl,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
|
||||
} catch (error) {
|
||||
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { consumePairing } from '@/lib/auth/pairing-store';
|
||||
|
||||
// Phone side of the cross-device QR login. The app POSTs the pairing code it
|
||||
// scanned; we hand back the OAuth token bundle the desktop stashed at
|
||||
// /api/auth/pair/create. The code is the only credential required — it is
|
||||
// high-entropy, single-use, and expires within ~2 minutes — so this route is
|
||||
// intentionally unauthenticated (the scanning device has no webmail cookies).
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
|
||||
if (!pairingCode || typeof pairingCode !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokens = consumePairing(pairingCode);
|
||||
if (!tokens) {
|
||||
// Unknown, expired, or already redeemed — do not distinguish.
|
||||
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
flow: 'oauth',
|
||||
server_url: tokens.serverUrl,
|
||||
access_token: tokens.accessToken,
|
||||
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
|
||||
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
|
||||
token_endpoint: tokens.tokenEndpoint,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import { setPairReauth } from '@/lib/auth/pair-reauth';
|
||||
|
||||
// Completes the step-up re-authentication for device pairing. The user was sent
|
||||
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
|
||||
// here we verify the returned code against the pending state and exchange it to
|
||||
// confirm a fresh login actually happened, then set the short-lived pairing
|
||||
// re-auth proof cookie. We deliberately do NOT issue a login session or write
|
||||
// any refresh-token cookies — the user is already signed in; this only proves
|
||||
// recency for the pairing action.
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
const { code, state } = await request.json();
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||
if (!pendingCookie) {
|
||||
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pending = decryptPayload(pendingCookie);
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
if (!pending) {
|
||||
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only honor pending sessions that were started for the reauth purpose, so
|
||||
// a normal login code can't be redirected into setting a pairing proof.
|
||||
if (pending.purpose !== 'reauth') {
|
||||
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
|
||||
}
|
||||
if (pending.state !== state) {
|
||||
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||
}
|
||||
const createdAt = pending.created_at as number;
|
||||
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
|
||||
}
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// A successful exchange proves the user just authenticated at the IdP (the
|
||||
// freshness is enforced by prompt=login on the authorize request). We don't
|
||||
// keep the resulting tokens.
|
||||
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
await setPairReauth();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
@@ -31,8 +31,14 @@ export async function POST(request: NextRequest) {
|
||||
server_id: bodyServerId,
|
||||
mobile_redirect_uri: rawMobileRedirectUri,
|
||||
mobile_state: rawMobileState,
|
||||
purpose: rawPurpose,
|
||||
} = await request.json();
|
||||
|
||||
// `reauth` drives the step-up flow for device pairing: it forces a fresh
|
||||
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
|
||||
// short-lived pairing re-auth proof instead of logging the user in again.
|
||||
const isReauth = rawPurpose === 'reauth';
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
@@ -62,7 +68,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
@@ -86,6 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||
...(isReauth ? { purpose: 'reauth' } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
@@ -96,8 +103,12 @@ export async function POST(request: NextRequest) {
|
||||
maxAge: SSO_PENDING_MAX_AGE,
|
||||
});
|
||||
|
||||
// Build authorize URL
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
|
||||
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
|
||||
// token exchange and refresh keep using the canonical discovered endpoints.
|
||||
const authorizeOverride =
|
||||
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
|
||||
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
@@ -110,6 +121,14 @@ export async function POST(request: NextRequest) {
|
||||
authUrl.searchParams.set('ui_locales', locale);
|
||||
}
|
||||
|
||||
// Force a fresh credential entry for step-up re-auth. prompt=login and
|
||||
// max_age=0 both ask the IdP to re-authenticate even if it has an active
|
||||
// session; honoring them depends on the IdP supporting these OIDC params.
|
||||
if (isReauth) {
|
||||
authUrl.searchParams.set('prompt', 'login');
|
||||
authUrl.searchParams.set('max_age', '0');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authorize_url: authUrl.toString(),
|
||||
state,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -10,77 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
*
|
||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||
* verbatim against the password hash and fails. The MFA token must instead be
|
||||
* supplied as a distinct field through the structured login endpoint.
|
||||
*
|
||||
* Tries three strategies:
|
||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||
* 2. ROPC grant without client_id
|
||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||
* This route drives that flow server-side (avoiding browser CORS against the
|
||||
* mail server, same as OAuth discovery):
|
||||
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||
* receiving a short-lived authorization `clientCode`.
|
||||
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||
* the code (with PKCE) for access/refresh tokens.
|
||||
*
|
||||
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||
* the (≈30s) code in every request.
|
||||
*/
|
||||
|
||||
async function tryTokenRequest(
|
||||
tokenEndpoint: string,
|
||||
params: URLSearchParams,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: params.toString(),
|
||||
});
|
||||
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||
// any client id unless `require_client_registration` is enabled (default off);
|
||||
// when it is enabled the admin must configure `oauthClientId` with this
|
||||
// redirect URI registered.
|
||||
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
if (!tokens.access_token) {
|
||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||
}
|
||||
|
||||
return { ok: true, tokens };
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
interface LoginResult {
|
||||
type?: string;
|
||||
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||
// camelCased server-side, not the struct fields (the request fields are).
|
||||
client_code?: string;
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
function trimUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
const candidates = [
|
||||
`${serverUrl}/auth/token`,
|
||||
`${serverUrl}/api/oauth/token`,
|
||||
];
|
||||
async function attemptLogin(
|
||||
upstreamUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp: string | undefined,
|
||||
redirectUri: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const base = trimUrl(upstreamUrl);
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||
if (probe.status !== 404 && probe.status !== 405) {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Network error - endpoint not reachable
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID
|
||||
|| DEFAULT_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||
|| '';
|
||||
|
||||
// PKCE proves the token exchange originates from the same client that
|
||||
// initiated the login, so no client secret is required for public clients.
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
// Step 1: structured login with a separate MFA token.
|
||||
let login: LoginResult;
|
||||
try {
|
||||
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'authCode',
|
||||
accountName: username,
|
||||
accountSecret: password,
|
||||
...(totp ? { mfaToken: totp } : {}),
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const detail = (await loginResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||
// A 404 means the server predates the structured login endpoint; let the
|
||||
// caller fall back to the legacy basic-auth path.
|
||||
return NextResponse.json(
|
||||
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
login = await loginResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||
}
|
||||
|
||||
return null;
|
||||
switch (login.type) {
|
||||
case 'authenticated':
|
||||
break;
|
||||
case 'mfaRequired':
|
||||
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||
case 'failure':
|
||||
default:
|
||||
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!login.client_code) {
|
||||
logger.warn('TOTP login: authenticated response missing client_code');
|
||||
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Step 2: exchange the authorization code for tokens.
|
||||
const tokenParams = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: login.client_code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
// Confidential clients still send their secret; harmless for public clients.
|
||||
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||
|
||||
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||
try {
|
||||
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||
}
|
||||
|
||||
tokens = await tokenResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||
}
|
||||
|
||||
logger.info('TOTP login succeeded');
|
||||
void recordLogin(username, base);
|
||||
return await storeAndRespond(
|
||||
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||
slot,
|
||||
serverId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||
await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
@@ -88,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
@@ -115,7 +211,7 @@ export async function POST(request: NextRequest) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
logger.warn('TOTP login: rejected non-public server URL');
|
||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
@@ -123,100 +219,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
// The redirect URI must be identical in the login and token-exchange steps,
|
||||
// and (when require_client_registration is on) registered for the client.
|
||||
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||
// fall back to the upstream URL so the two steps still agree.
|
||||
const redirectUri =
|
||||
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||
? bodyRedirectUri
|
||||
: trimUrl(upstreamUrl);
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
// Strategy 1: ROPC with client_id (if configured)
|
||||
if (clientId) {
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||
if (clientSecret) params.set('client_secret', clientSecret);
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 2: ROPC without client_id
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
|
||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||
return NextResponse.json({
|
||||
error: 'token_exchange_failed',
|
||||
detail: 'All token exchange strategies failed',
|
||||
attempts,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { parseISO } from 'date-fns';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
/**
|
||||
* POST /api/calendar-agenda
|
||||
*
|
||||
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
|
||||
* account from the stored Stalwart auth context, queries upcoming
|
||||
* CalendarEvents over JMAP, expands recurring series server-side, and returns
|
||||
* a slim, structured-cloneable agenda the plugin can render directly.
|
||||
*
|
||||
* Credentials never leave the server — the sandboxed plugin only ever sees
|
||||
* the resulting agenda DTOs.
|
||||
*
|
||||
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
|
||||
*/
|
||||
|
||||
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
|
||||
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
|
||||
|
||||
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
|
||||
// agenda actually needs (start/recurrence/display fields).
|
||||
const EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
|
||||
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
|
||||
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||
] as const;
|
||||
|
||||
interface JmapSession {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string;
|
||||
uid: string | null;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
status: string | null;
|
||||
color: string | null;
|
||||
location: string | null;
|
||||
calendarId: string | null;
|
||||
}
|
||||
|
||||
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
|
||||
// getEventEndDate. We can't import that module here because it transitively
|
||||
// pulls in a "use client" calendar component (which throws at load on the
|
||||
// server). Logic mirrors the originals.
|
||||
function parseDurationMinutes(duration: string | undefined): number {
|
||||
if (!duration) return 0;
|
||||
let total = 0;
|
||||
const week = duration.match(/(\d+)W/);
|
||||
const day = duration.match(/(\d+)D/);
|
||||
const hour = duration.match(/(\d+)H/);
|
||||
const min = duration.match(/(\d+)M/);
|
||||
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
|
||||
if (day) total += parseInt(day[1], 10) * 24 * 60;
|
||||
if (hour) total += parseInt(hour[1], 10) * 60;
|
||||
if (min) total += parseInt(min[1], 10);
|
||||
return total;
|
||||
}
|
||||
|
||||
function eventStart(event: Partial<CalendarEvent>): Date {
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start as string);
|
||||
}
|
||||
|
||||
function eventEnd(event: Partial<CalendarEvent>): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
const start = eventStart(event);
|
||||
if (!event.duration) return start;
|
||||
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
|
||||
}
|
||||
|
||||
function firstLocationName(event: Partial<CalendarEvent>): string | null {
|
||||
const locations = event.locations;
|
||||
if (!locations || typeof locations !== 'object') return null;
|
||||
for (const loc of Object.values(locations)) {
|
||||
const name = (loc as { name?: unknown })?.name;
|
||||
if (typeof name === 'string' && name.trim()) return name.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
|
||||
const ids = event.calendarIds;
|
||||
if (!ids || typeof ids !== 'object') return null;
|
||||
const keys = Object.keys(ids);
|
||||
return keys.length > 0 ? keys[0] : null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { days?: unknown; limit?: unknown } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
/* empty body is fine */
|
||||
}
|
||||
const days = clampInt(body.days, 1, 90, 7);
|
||||
const limit = clampInt(body.limit, 1, 200, 50);
|
||||
|
||||
// ── Resolve the calendar account from the JMAP session ──
|
||||
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
|
||||
// We deliberately avoid /.well-known/jmap: it 301s to the server's
|
||||
// configured public hostname, which the host process may not be able to
|
||||
// resolve (the browser client rewrites those URLs back to the origin for
|
||||
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
|
||||
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
|
||||
}
|
||||
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
|
||||
if (!accountId) {
|
||||
// No calendar account for this user — return an empty agenda, not an error.
|
||||
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
|
||||
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
|
||||
using.push('urn:ietf:params:jmap:principals:owner');
|
||||
}
|
||||
|
||||
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
||||
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
||||
const apiUrl = `${creds.serverUrl}/jmap/`;
|
||||
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
// Expand from the start of today so all-day / already-running events still
|
||||
// show in the agenda.
|
||||
const windowStart = new Date(now);
|
||||
windowStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// ── 1) Query event IDs in range + load calendars (colours) ──
|
||||
const queryReq = {
|
||||
using,
|
||||
methodCalls: [
|
||||
[
|
||||
'CalendarEvent/query',
|
||||
{
|
||||
accountId,
|
||||
// Mirror the app's calendar store: an { after, before } window lets
|
||||
// Stalwart evaluate recurrence so masters with occurrences in range
|
||||
// are returned (a `before`-only filter can drop unbounded series).
|
||||
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||
limit: 1000,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'Calendar/get',
|
||||
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||
'c',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
|
||||
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||
if (!queryResp) {
|
||||
const err = findResponse(queryRes, 'error', '0');
|
||||
return NextResponse.json(
|
||||
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const ids = (queryResp.ids as string[]) || [];
|
||||
|
||||
const calColors = new Map<string, { name: string; color: string | null }>();
|
||||
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
|
||||
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
|
||||
if (typeof cal.id === 'string') {
|
||||
calColors.set(cal.id, {
|
||||
name: typeof cal.name === 'string' ? cal.name : '',
|
||||
color: typeof cal.color === 'string' ? cal.color : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
|
||||
}
|
||||
|
||||
// ── 2) Fetch full event objects (batched) ──
|
||||
const raw: Array<Record<string, unknown>> = [];
|
||||
const BATCH = 100;
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const batch = ids.slice(i, i + BATCH);
|
||||
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
using,
|
||||
methodCalls: [
|
||||
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
|
||||
],
|
||||
});
|
||||
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
|
||||
}
|
||||
|
||||
// ── 3) Normalize + expand recurrences server-side ──
|
||||
const normalized = raw
|
||||
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
|
||||
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
|
||||
// Drop malformed events without a parseable start (would crash format()/
|
||||
// parseISO downstream) — mirrors the calendar store guard (#316).
|
||||
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
|
||||
|
||||
const expanded = expandRecurringEvents(
|
||||
normalized,
|
||||
windowStart.toISOString(),
|
||||
horizon.toISOString(),
|
||||
);
|
||||
|
||||
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
|
||||
const agenda: AgendaEvent[] = expanded
|
||||
.filter((e) => eventEnd(e).getTime() >= now.getTime())
|
||||
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
|
||||
.slice(0, limit)
|
||||
.map((e) => {
|
||||
const calId = firstCalendarId(e);
|
||||
const cal = calId ? calColors.get(calId) : undefined;
|
||||
return {
|
||||
id: String(e.id ?? ''),
|
||||
uid: e.uid ?? null,
|
||||
title: (e.title ?? '').trim() || '(no title)',
|
||||
start: eventStart(e).toISOString(),
|
||||
end: eventEnd(e).toISOString(),
|
||||
allDay: !!e.showWithoutTime,
|
||||
status: e.status ?? null,
|
||||
color: e.color || cal?.color || null,
|
||||
location: firstLocationName(e),
|
||||
calendarId: calId,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
|
||||
} catch (error) {
|
||||
// `fetch failed` from undici is too generic to debug — the real reason
|
||||
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
|
||||
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||
logger.error('Calendar agenda error', {
|
||||
error: err?.message ?? 'Unknown',
|
||||
causeCode: err?.cause?.code,
|
||||
causeMessage: err?.cause?.message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
|
||||
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
|
||||
* fallback for other servers. Returns null if neither yields a usable session.
|
||||
*/
|
||||
async function fetchJmapSession(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<JmapSession | null> {
|
||||
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const session = (await res.json()) as JmapSession;
|
||||
if (session && typeof session === 'object' && session.primaryAccounts) {
|
||||
return session;
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
payload: unknown,
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`JMAP request failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function findResponse(
|
||||
res: unknown,
|
||||
name: string,
|
||||
callId: string,
|
||||
): Record<string, unknown> | null {
|
||||
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
|
||||
if (!Array.isArray(responses)) return null;
|
||||
for (const entry of responses) {
|
||||
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
|
||||
return entry[1] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+66
-36
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
* post-build configuration for Docker deployments.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 4. Default values
|
||||
* 1. Per-domain branding override (admin-configured, matched on request host)
|
||||
* 2. Admin dashboard overrides (data/admin/config.json)
|
||||
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 5. Default values
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
logger.debug('Config requested');
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const host = pickRequestHost(request);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
|
||||
// Per-domain override wins over the global value, but only when the
|
||||
// entry explicitly sets that key. Otherwise we fall through to the
|
||||
// global admin/env/default chain.
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === 'string' && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||
|
||||
return NextResponse.json({
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
},
|
||||
{
|
||||
// Branding varies by host, so any cache between us and the browser
|
||||
// must key its entry by the host headers we consulted.
|
||||
headers: { Vary: 'Host, X-Forwarded-Host' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
*/
|
||||
|
||||
const ACCOUNT_ID = 'dev-account-001';
|
||||
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
|
||||
const emailCreationIds = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mailboxes
|
||||
@@ -119,9 +121,9 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
||||
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
||||
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
||||
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
|
||||
subject: 'Project Update - Q1 Review',
|
||||
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
|
||||
hasAttachment: true,
|
||||
@@ -721,7 +723,18 @@ const emails: MockEmail[] = [
|
||||
// Identities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITIES = [
|
||||
type MockIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: Array<{ name?: string; email: string }> | null;
|
||||
bcc: Array<{ name?: string; email: string }> | null;
|
||||
textSignature: string | null;
|
||||
htmlSignature: string | null;
|
||||
mayDelete: boolean;
|
||||
};
|
||||
|
||||
const IDENTITIES: MockIdentity[] = [
|
||||
{
|
||||
id: 'identity-001',
|
||||
name: 'Dev User',
|
||||
@@ -1533,6 +1546,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
bodyValues: {},
|
||||
};
|
||||
emails.unshift(newEmail);
|
||||
emailCreationIds.set(key, newId);
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
@@ -1559,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
const create = args.create as Record<string, unknown> | undefined;
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
if (create) {
|
||||
for (const key of Object.keys(create)) {
|
||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
||||
for (const [key, data] of Object.entries(create)) {
|
||||
const newId = `identity-${Date.now()}-${key}`;
|
||||
IDENTITIES.push({
|
||||
id: newId,
|
||||
name: (data.name as string) || '',
|
||||
email: (data.email as string) || '',
|
||||
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
|
||||
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
|
||||
textSignature: (data.textSignature as string | null) ?? null,
|
||||
htmlSignature: (data.htmlSignature as string | null) ?? null,
|
||||
mayDelete: true,
|
||||
});
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
|
||||
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
if (update) {
|
||||
for (const [id, changes] of Object.entries(update)) {
|
||||
const identity = IDENTITIES.find((i) => i.id === id);
|
||||
if (identity) {
|
||||
// Email is immutable per the identity form, so it's never in `changes`.
|
||||
if (changes.name !== undefined) identity.name = changes.name as string;
|
||||
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
|
||||
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
|
||||
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
|
||||
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = args.destroy as string[] | undefined;
|
||||
if (destroy) {
|
||||
for (const id of destroy) {
|
||||
const idx = IDENTITIES.findIndex((i) => i.id === id);
|
||||
if (idx !== -1) {
|
||||
IDENTITIES.splice(idx, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
|
||||
}
|
||||
|
||||
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1575,8 +1631,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
|
||||
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string; sendAt?: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
|
||||
if (create) {
|
||||
for (const [key, value] of Object.entries(create)) {
|
||||
const id = `submission-${Date.now()}-${key}`;
|
||||
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
|
||||
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
|
||||
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
|
||||
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
|
||||
? Date.now() + holdForSeconds * 1000
|
||||
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
|
||||
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
|
||||
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
|
||||
if (delayedUntil && value.emailId && value.identityId) {
|
||||
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
|
||||
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
|
||||
if (update) {
|
||||
for (const [id, patch] of Object.entries(update)) {
|
||||
const submission = scheduledSubmissions.find(s => s.id === id);
|
||||
if (submission && patch.undoStatus) {
|
||||
submission.undoStatus = patch.undoStatus;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
|
||||
const position = Number(args.position || 0);
|
||||
const limit = Number(args.limit || 50);
|
||||
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
|
||||
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1639,6 +1738,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
|
||||
'Identity/get': handleIdentityGet,
|
||||
'Identity/set': handleIdentitySet,
|
||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||
'Quota/get': handleQuotaGet,
|
||||
'VacationResponse/get': handleVacationResponseGet,
|
||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
||||
@@ -1776,7 +1877,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:quota': {},
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
|
||||
+17
-12
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
|
||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||
|
||||
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
|
||||
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
|
||||
// without spamming the DevTools console with red 404 errors. Avatar.tsx
|
||||
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
|
||||
const TRANSPARENT_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MISSING_FAVICON_HEADERS = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400', // 1 day
|
||||
'X-Bulwark-Favicon': 'missing',
|
||||
};
|
||||
|
||||
// Strict domain validation to prevent SSRF
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
|
||||
// Check negative cache (domains known to have no favicon)
|
||||
const neg = negativeCache.get(normalizedDomain);
|
||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Check cache
|
||||
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!upstream.ok) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
|
||||
if (data.byteLength < 10) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function GET() {
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
type: p.type,
|
||||
// Requested execution tier; clients gate the same-origin privileged
|
||||
// sandbox on this (plus signature + approval + consent).
|
||||
tier: p.tier,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
@@ -57,6 +60,9 @@ export async function GET() {
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||
// strings via api.i18n.t().
|
||||
locales: p.locales,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
|
||||
@@ -3,11 +3,18 @@ import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
@@ -17,13 +24,21 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
@@ -34,8 +49,15 @@ export async function GET(
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const iconUrl =
|
||||
domainOverrides.pwaIconUrl ||
|
||||
domainOverrides.faviconUrl ||
|
||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||
if (!iconUrl) {
|
||||
@@ -45,11 +67,14 @@ export async function GET(
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
@@ -61,7 +86,7 @@ export async function GET(
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Variant → target output size + admin config key.
|
||||
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
|
||||
* the slot the manifest tells the browser about.
|
||||
*/
|
||||
const VARIANTS = {
|
||||
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
|
||||
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
|
||||
} as const;
|
||||
|
||||
type Variant = keyof typeof VARIANTS;
|
||||
|
||||
// Cache resized images keyed by (variant, source URL).
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ variant: string }> },
|
||||
) {
|
||||
const { variant: variantParam } = await params;
|
||||
if (!(variantParam in VARIANTS)) {
|
||||
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
|
||||
}
|
||||
const { width, height, configKey } = VARIANTS[variantParam as Variant];
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const sourceEntry = sources[configKey];
|
||||
const screenshotUrl =
|
||||
domainOverrides[configKey] ||
|
||||
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
|
||||
if (!screenshotUrl) {
|
||||
return new NextResponse('No PWA screenshot configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
const cacheKey = `${variantParam}|${screenshotUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(screenshotUrl);
|
||||
// 'cover' fills the target box without letterboxing - screenshots benefit
|
||||
// more from cropping than from a transparent frame around them. Users get
|
||||
// a hint about the recommended aspect ratio in the admin UI.
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(width, height, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA screenshot:', err);
|
||||
return new NextResponse('Failed to generate screenshot', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
const valuesObj = { ...(values as Record<string, unknown>) };
|
||||
|
||||
// Telemetry consent lives in the telemetry state file, not admin config, so
|
||||
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
|
||||
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
|
||||
if (step === 'security' && 'telemetryConsent' in valuesObj) {
|
||||
const consent = valuesObj.telemetryConsent;
|
||||
delete valuesObj.telemetryConsent;
|
||||
if (consent !== 'on' && consent !== 'off') {
|
||||
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
|
||||
}
|
||||
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
|
||||
const { source } = await effectiveConsent();
|
||||
if (source !== 'env') {
|
||||
const tstate = await loadState();
|
||||
tstate.consent = consent;
|
||||
if (consent === 'on' && !tstate.consentedAt) {
|
||||
tstate.consentedAt = new Date().toISOString();
|
||||
}
|
||||
await saveState(tstate);
|
||||
await reschedule();
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||
for (const [key, value] of Object.entries(valuesObj)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
||||
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
||||
// provider } here via api.http.post; we forward to a free translation backend
|
||||
// and return { translatedText, detectedSource } in a stable shape.
|
||||
//
|
||||
// Two providers:
|
||||
// - "mymemory" — public MyMemory API, no configuration required. Its
|
||||
// langpair needs an explicit source language, so when the
|
||||
// plugin asks for "auto" we detect it locally first.
|
||||
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
|
||||
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
|
||||
// source auto-detection.
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
// Long bodies are split into ~480-char chunks for MyMemory and translated
|
||||
// sequentially, so allow enough headroom for ~10 round-trips.
|
||||
const TIMEOUT_MS = 25000;
|
||||
|
||||
type Provider = 'mymemory' | 'libretranslate';
|
||||
|
||||
interface TranslateBody {
|
||||
text?: unknown;
|
||||
target?: unknown;
|
||||
source?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
|
||||
interface TranslateResult {
|
||||
translatedText: string;
|
||||
detectedSource?: string;
|
||||
}
|
||||
|
||||
// ─── Lightweight language detection ───────────────────────────
|
||||
//
|
||||
// MyMemory has no auto-detect, so we infer a source language from the text.
|
||||
// Non-Latin scripts are decided by Unicode range; Latin-script European
|
||||
// languages are scored by stop-word frequency. Detection only needs to be good
|
||||
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
|
||||
// already in the target language.
|
||||
|
||||
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
|
||||
[/[-ヿ]/, 'ja'], // Hiragana / Katakana
|
||||
[/[가-]/, 'ko'], // Hangul
|
||||
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
|
||||
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
|
||||
[/[Ͱ-Ͽ]/, 'el'], // Greek
|
||||
[/[-ۿ]/, 'ar'], // Arabic
|
||||
[/[-]/, 'he'], // Hebrew
|
||||
[/[ऀ-ॿ]/, 'hi'], // Devanagari
|
||||
];
|
||||
|
||||
// Distinctive stop words per Latin-script language from the manifest's option
|
||||
// list. Kept small and high-signal to avoid cross-language collisions.
|
||||
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
|
||||
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
|
||||
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
|
||||
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
|
||||
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
|
||||
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
|
||||
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
|
||||
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
|
||||
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
|
||||
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
|
||||
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
|
||||
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
|
||||
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
|
||||
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
|
||||
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
|
||||
};
|
||||
|
||||
function detectLanguage(text: string): string {
|
||||
const sample = text.slice(0, 1000);
|
||||
for (const [range, lang] of SCRIPT_RANGES) {
|
||||
if (range.test(sample)) {
|
||||
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
|
||||
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
|
||||
if (words.length === 0) return 'en';
|
||||
const counts: Record<string, number> = {};
|
||||
const wordSet = new Set(words);
|
||||
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
|
||||
let score = 0;
|
||||
for (const stop of stops) if (wordSet.has(stop)) score += 1;
|
||||
counts[lang] = score;
|
||||
}
|
||||
let best = 'en';
|
||||
let bestScore = -1;
|
||||
for (const [lang, score] of Object.entries(counts)) {
|
||||
if (score > bestScore) {
|
||||
best = lang;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestScore > 0 ? best : 'en';
|
||||
}
|
||||
|
||||
function baseLang(code: string): string {
|
||||
return String(code || '').toLowerCase().split('-')[0];
|
||||
}
|
||||
|
||||
// ─── Chunking ─────────────────────────────────────────────────
|
||||
//
|
||||
// MyMemory's free endpoint caps each request's `q` at 500 characters and
|
||||
// silently returns only the translated prefix beyond that — which is why long
|
||||
// emails came back truncated ("…about Sel…"). We split the text into
|
||||
// line-aware chunks under the limit, translate each, then rejoin so the whole
|
||||
// body is covered.
|
||||
|
||||
const MYMEMORY_CHUNK = 480;
|
||||
|
||||
function chunkText(text: string, max: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let cur = '';
|
||||
const flush = () => {
|
||||
if (cur) {
|
||||
chunks.push(cur);
|
||||
cur = '';
|
||||
}
|
||||
};
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.length > max) {
|
||||
flush();
|
||||
// A single over-long line (e.g. a long URL list): split on words, and
|
||||
// hard-cut any word that is itself longer than the limit.
|
||||
let seg = '';
|
||||
for (const word of line.split(' ')) {
|
||||
const piece = seg ? seg + ' ' + word : word;
|
||||
if (piece.length > max) {
|
||||
if (seg) {
|
||||
chunks.push(seg);
|
||||
seg = '';
|
||||
}
|
||||
if (word.length > max) {
|
||||
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
|
||||
} else {
|
||||
seg = word;
|
||||
}
|
||||
} else {
|
||||
seg = piece;
|
||||
}
|
||||
}
|
||||
if (seg) chunks.push(seg);
|
||||
continue;
|
||||
}
|
||||
if (cur && cur.length + 1 + line.length > max) flush();
|
||||
cur = cur ? cur + '\n' + line : line;
|
||||
}
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Providers ────────────────────────────────────────────────
|
||||
|
||||
async function mymemoryRequest(
|
||||
q: string,
|
||||
langpair: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
const url = new URL('https://api.mymemory.translated.net/get');
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('langpair', langpair);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(`MyMemory returned ${res.status}`);
|
||||
}
|
||||
const status = Number(data.responseStatus);
|
||||
if (status && status !== 200) {
|
||||
throw new Error(data.responseDetails || `MyMemory error ${status}`);
|
||||
}
|
||||
const translatedText = data.responseData?.translatedText || '';
|
||||
if (!translatedText) {
|
||||
throw new Error('MyMemory returned no translation');
|
||||
}
|
||||
return translatedText;
|
||||
}
|
||||
|
||||
async function translateMyMemory(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
|
||||
const tgt = baseLang(target);
|
||||
// Nothing to do if already in the target language; the plugin skips display.
|
||||
if (detected === tgt) {
|
||||
return { translatedText: text, detectedSource: detected };
|
||||
}
|
||||
const langpair = `${detected}|${tgt}`;
|
||||
const chunks = chunkText(text, MYMEMORY_CHUNK);
|
||||
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
|
||||
// usually one or two chunks.
|
||||
const translated: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
translated.push(await mymemoryRequest(chunk, langpair, signal));
|
||||
}
|
||||
return { translatedText: translated.join('\n'), detectedSource: detected };
|
||||
}
|
||||
|
||||
async function translateLibre(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const endpoint = process.env.LIBRETRANSLATE_URL;
|
||||
if (!endpoint) {
|
||||
throw new Error('LibreTranslate is not configured on this server');
|
||||
}
|
||||
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
|
||||
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
q: text,
|
||||
source: source || 'auto',
|
||||
target: baseLang(target),
|
||||
format: 'text',
|
||||
...(apiKey ? { api_key: apiKey } : {}),
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
|
||||
}
|
||||
if (!data.translatedText) {
|
||||
throw new Error(data.error || 'LibreTranslate returned no translation');
|
||||
}
|
||||
return {
|
||||
translatedText: data.translatedText,
|
||||
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Route ────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: TranslateBody;
|
||||
try {
|
||||
body = (await request.json()) as TranslateBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
||||
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
||||
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const result =
|
||||
provider === 'libretranslate'
|
||||
? await translateLibre(text, source, target, controller.signal)
|
||||
: await translateMyMemory(text, source, target, controller.signal);
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Translation failed';
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -70,7 +70,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-border: #262626;
|
||||
--color-border: rgba(128, 128, 128, 0.3);
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-background: #0a0a0a;
|
||||
@@ -242,8 +242,8 @@ body {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-text {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-14
@@ -1,5 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
);
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === "string" && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
configManager.get<string>("appName") ||
|
||||
branded<string>("appName", "") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = configManager.get<string>("appShortName") || appName;
|
||||
const shortName = branded<string>("appShortName", "") || appName;
|
||||
const description =
|
||||
configManager.get<string>("appDescription") ||
|
||||
branded<string>("appDescription", "") ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
||||
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
||||
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
|
||||
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
|
||||
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
||||
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
||||
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
|
||||
// are the built-in placeholder paths and not real custom icons.
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
|
||||
// env var, or per-domain override), serve dynamically resized PNGs via
|
||||
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
|
||||
// sources marked "default" are the built-in placeholder paths and not
|
||||
// real custom icons.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const hasCustomIcon =
|
||||
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
|
||||
!!domainOverrides.pwaIconUrl ||
|
||||
!!domainOverrides.faviconUrl ||
|
||||
sources.pwaIconUrl?.source !== "default" ||
|
||||
sources.faviconUrl?.source !== "default";
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
@@ -73,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||
// otherwise fall back to the built-in Bulwark screenshots from public/.
|
||||
screenshots: (() => {
|
||||
const hasMobile =
|
||||
!!domainOverrides.pwaScreenshotMobileUrl ||
|
||||
sources.pwaScreenshotMobileUrl?.source !== "default";
|
||||
const hasDesktop =
|
||||
!!domainOverrides.pwaScreenshotDesktopUrl ||
|
||||
sources.pwaScreenshotDesktopUrl?.source !== "default";
|
||||
return [
|
||||
hasMobile
|
||||
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
hasDesktop
|
||||
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
];
|
||||
})(),
|
||||
protocol_handlers: [
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||
import { MapPin, Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventColor } from "./event-card";
|
||||
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
@@ -147,7 +147,6 @@ export function CalendarAgendaView({
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = getEventStartDate(ev);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
const end = getEventEndDate(ev);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
onShareCalendar?: (calendar: Calendar) => void;
|
||||
onCreateEvent?: (calendar: Calendar) => void;
|
||||
onClearCalendar?: (calendar: Calendar) => void;
|
||||
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onResetColor,
|
||||
onShareCalendar,
|
||||
onCreateEvent,
|
||||
onClearCalendar,
|
||||
@@ -93,7 +96,9 @@ export function CalendarSidebarPanel({
|
||||
);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||
@@ -215,6 +220,16 @@ export function CalendarSidebarPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (calendarId: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await setDefaultCalendar(client, calendarId);
|
||||
toast.success(tMgmt('default_updated'));
|
||||
} catch {
|
||||
toast.error(tMgmt('error_default'));
|
||||
}
|
||||
};
|
||||
|
||||
if (calendars.length === 0 && !onSubscribe) return null;
|
||||
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
@@ -302,10 +317,13 @@ export function CalendarSidebarPanel({
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
|
||||
const canChangeColor = !!onColorChange;
|
||||
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
const canResetColor = !!onResetColor && hasColorOverride;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
@@ -324,6 +342,13 @@ export function CalendarSidebarPanel({
|
||||
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<ContextMenuItem
|
||||
icon={Star}
|
||||
label={tMgmt('set_default')}
|
||||
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
|
||||
/>
|
||||
)}
|
||||
{canChangeColor && (
|
||||
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||
<div className="px-2 py-1.5 w-[200px]">
|
||||
@@ -335,6 +360,13 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{canResetColor && (
|
||||
<ContextMenuItem
|
||||
icon={Shuffle}
|
||||
label={tMgmt('random_color')}
|
||||
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
|
||||
}
|
||||
|
||||
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
||||
// A local color override on a shared calendar wins over per-event colors,
|
||||
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
|
||||
if (calendar?.colorIsLocalOverride && calendar.color) {
|
||||
return sanitizeColor(calendar.color);
|
||||
}
|
||||
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
||||
}
|
||||
|
||||
@@ -66,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
@@ -78,7 +83,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = getEventEndDate(event);
|
||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||
const safeFormat = (d: Date, fmt: string) => {
|
||||
if (isNaN(d.getTime())) return "--:--";
|
||||
try { return format(d, fmt); } catch { return "--:--"; }
|
||||
};
|
||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
@@ -153,7 +162,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesBefore && "-ml-0.5",
|
||||
continuesAfter && "pr-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||
if (!event.recurrenceRules?.length) return null;
|
||||
const freq = event.recurrenceRules[0].frequency;
|
||||
const labels: Record<string, string> = {
|
||||
daily: t("recurrence.daily"),
|
||||
weekly: t("recurrence.weekly"),
|
||||
monthly: t("recurrence.monthly"),
|
||||
yearly: t("recurrence.yearly"),
|
||||
};
|
||||
return labels[freq] || null;
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
}
|
||||
|
||||
export function EventDetailPopover({
|
||||
@@ -130,6 +124,7 @@ export function EventDetailPopover({
|
||||
isMobile,
|
||||
}: EventDetailPopoverProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
@@ -143,6 +138,8 @@ export function EventDetailPopover({
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = getEventEndDate(event);
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
@@ -156,7 +153,7 @@ export function EventDetailPopover({
|
||||
}, [event.virtualLocations]);
|
||||
|
||||
const participants = useMemo(() => getParticipantList(event), [event]);
|
||||
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
|
||||
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
|
||||
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
|
||||
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
@@ -331,16 +328,50 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
{isMultiDay ? (
|
||||
event.showWithoutTime ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)} –
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(displayEndDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(startDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(endDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
||||
return dur;
|
||||
}
|
||||
|
||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||
|
||||
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
||||
|
||||
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
||||
return labels.join(", ");
|
||||
}
|
||||
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||
if (!event.recurrenceRules?.length) return null;
|
||||
const freq = event.recurrenceRules[0].frequency;
|
||||
const labels: Record<string, string> = {
|
||||
daily: t("recurrence.daily"),
|
||||
weekly: t("recurrence.weekly"),
|
||||
monthly: t("recurrence.monthly"),
|
||||
yearly: t("recurrence.yearly"),
|
||||
};
|
||||
return labels[freq] || null;
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
}
|
||||
|
||||
export function EventModal({
|
||||
@@ -186,6 +180,7 @@ export function EventModal({
|
||||
isMobile = false,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
const isEdit = !!event;
|
||||
@@ -270,8 +265,35 @@ export function EventModal({
|
||||
});
|
||||
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
||||
if (!event?.recurrenceRules?.length) return "none";
|
||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
||||
const rule = event.recurrenceRules[0];
|
||||
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
|
||||
});
|
||||
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
|
||||
if (!event?.recurrenceRules?.length) return null;
|
||||
const rule = event.recurrenceRules[0];
|
||||
return isSimpleRecurrenceRule(rule) ? null : rule;
|
||||
});
|
||||
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
|
||||
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
|
||||
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
|
||||
|
||||
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
|
||||
setCustomRule(rule);
|
||||
setRecurrence("custom");
|
||||
setShowRecurrenceEditor(false);
|
||||
}, []);
|
||||
|
||||
const handleRecurrenceEditorCancel = useCallback(() => {
|
||||
setShowRecurrenceEditor(false);
|
||||
if (!customRule) {
|
||||
setRecurrence(recurrenceBeforeCustomRef.current);
|
||||
}
|
||||
}, [customRule]);
|
||||
|
||||
const customRuleSummary = useMemo(
|
||||
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
|
||||
[customRule, t, locale]
|
||||
);
|
||||
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||
if (!event?.alerts) return [];
|
||||
@@ -444,7 +466,9 @@ export function EventModal({
|
||||
data.virtualLocations = null;
|
||||
}
|
||||
|
||||
if (recurrence !== "none") {
|
||||
if (recurrence === "custom" && customRule) {
|
||||
data.recurrenceRules = [customRule];
|
||||
} else if (recurrence !== "none" && recurrence !== "custom") {
|
||||
data.recurrenceRules = [{
|
||||
"@type": "RecurrenceRule",
|
||||
frequency: recurrence,
|
||||
@@ -499,9 +523,14 @@ export function EventModal({
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
data.replyTo = null;
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
@@ -511,7 +540,7 @@ export function EventModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -593,7 +622,6 @@ export function EventModal({
|
||||
|
||||
if (isAttendeeMode && event) {
|
||||
const startD = getEventStartDate(event);
|
||||
const durMin = parseDuration(event.duration);
|
||||
const endD = getEventEndDate(event);
|
||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||
const participants = getParticipantList(event);
|
||||
@@ -621,14 +649,42 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium">{formatEventDate(displayEnd)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
@@ -709,7 +765,7 @@ export function EventModal({
|
||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||
const viewParticipants = getParticipantList(event);
|
||||
const recurrenceLabel = getRecurrenceLabel(event, t);
|
||||
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
|
||||
const alertLabel = getAlertLabel(event, t);
|
||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||
const color = getEventColor(event, eventCalendar);
|
||||
@@ -742,17 +798,55 @@ export function EventModal({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durMin)})
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1065,17 +1159,51 @@ export function EventModal({
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as RecurrenceOption;
|
||||
if (value === "custom") {
|
||||
recurrenceBeforeCustomRef.current = recurrence;
|
||||
setRecurrence("custom");
|
||||
setShowRecurrenceEditor(true);
|
||||
} else {
|
||||
setRecurrence(value);
|
||||
setShowRecurrenceEditor(false);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
|
||||
</select>
|
||||
{recurrence === "custom" && !showRecurrenceEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecurrenceEditor(true)}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t("recurrence.edit_custom")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRecurrenceEditor && (
|
||||
<RecurrenceEditor
|
||||
rule={customRule}
|
||||
eventStart={(() => {
|
||||
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
})()}
|
||||
onSave={handleRecurrenceEditorSave}
|
||||
onCancel={handleRecurrenceEditorCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
|
||||
interface Participant {
|
||||
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
|
||||
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium truncate">{s.name || s.email}</div>
|
||||
{s.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
||||
)}
|
||||
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{s.name || s.email}</div>
|
||||
{s.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { addYears, format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
|
||||
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
|
||||
type MonthlyMode = "day" | "nth";
|
||||
type EndsMode = "never" | "on" | "after";
|
||||
|
||||
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
|
||||
|
||||
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
|
||||
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
|
||||
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||
|
||||
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
|
||||
daily: "recurrence.editor_unit_days",
|
||||
weekly: "recurrence.editor_unit_weeks",
|
||||
monthly: "recurrence.editor_unit_months",
|
||||
yearly: "recurrence.editor_unit_years",
|
||||
};
|
||||
|
||||
type CalendarT = ReturnType<typeof useTranslations>;
|
||||
|
||||
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
|
||||
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
|
||||
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
|
||||
}
|
||||
|
||||
function monthName(month: number, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
|
||||
}
|
||||
|
||||
function nthLabel(nth: number, t: CalendarT): string {
|
||||
if (nth === -1) return t("recurrence.nth_last");
|
||||
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
|
||||
return String(nth);
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an "nth weekday" pattern from a rule, accepting both the
|
||||
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
|
||||
*/
|
||||
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
|
||||
if (rule.byDay?.length === 1) {
|
||||
const nd = rule.byDay[0];
|
||||
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
|
||||
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
|
||||
* dropdown presets produce, i.e. it needs no custom editor to represent.
|
||||
*/
|
||||
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
|
||||
return (
|
||||
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
|
||||
(!rule.interval || rule.interval === 1) &&
|
||||
!rule.byDay?.length &&
|
||||
!rule.byMonthDay?.length &&
|
||||
!rule.byMonth?.length &&
|
||||
!rule.byYearDay?.length &&
|
||||
!rule.byWeekNo?.length &&
|
||||
!rule.bySetPosition?.length &&
|
||||
!rule.count &&
|
||||
!rule.until
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable summary of a recurrence rule, e.g.
|
||||
* "Every 2 months on the third Thursday · 12 occurrences".
|
||||
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
||||
*/
|
||||
export function buildRecurrenceSummary(
|
||||
rule: CalendarRecurrenceRule,
|
||||
t: CalendarT,
|
||||
locale: string,
|
||||
): string | null {
|
||||
const interval = rule.interval || 1;
|
||||
let base: string;
|
||||
switch (rule.frequency) {
|
||||
case "daily":
|
||||
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
|
||||
break;
|
||||
case "weekly":
|
||||
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
|
||||
break;
|
||||
case "monthly":
|
||||
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
|
||||
break;
|
||||
case "yearly":
|
||||
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = [base];
|
||||
|
||||
if (rule.frequency === "weekly" && rule.byDay?.length) {
|
||||
const days = rule.byDay
|
||||
.filter((d) => WEEKDAYS.includes(d.day))
|
||||
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
|
||||
.map((d) => weekdayName(d.day, locale, "short"))
|
||||
.join(", ");
|
||||
if (days) parts.push(t("recurrence.on_days", { days }));
|
||||
}
|
||||
|
||||
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
|
||||
if (rule.frequency === "yearly" && rule.byMonth?.length) {
|
||||
const m = parseInt(rule.byMonth[0], 10);
|
||||
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
|
||||
}
|
||||
const nthDay = getNthDay(rule);
|
||||
if (nthDay) {
|
||||
parts.push(t("recurrence.on_the_nth", {
|
||||
nth: nthLabel(nthDay.nth, t),
|
||||
day: weekdayName(nthDay.day, locale),
|
||||
}));
|
||||
} else if (rule.byMonthDay?.length) {
|
||||
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
|
||||
}
|
||||
}
|
||||
|
||||
let summary = parts.join(" ");
|
||||
if (rule.count) {
|
||||
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
|
||||
} else if (rule.until) {
|
||||
const d = new Date(rule.until);
|
||||
if (!isNaN(d.getTime())) {
|
||||
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
interface RecurrenceEditorProps {
|
||||
rule: CalendarRecurrenceRule | null;
|
||||
eventStart: Date;
|
||||
onSave: (rule: CalendarRecurrenceRule) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
|
||||
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
||||
const initialNthDay = rule ? getNthDay(rule) : null;
|
||||
|
||||
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
|
||||
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
|
||||
? (rule.frequency as EditorFrequency)
|
||||
: "weekly"
|
||||
);
|
||||
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
|
||||
const [weekDays, setWeekDays] = useState<string[]>(() => {
|
||||
if (rule?.frequency === "weekly" && rule.byDay?.length) {
|
||||
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
|
||||
if (days.length) return days;
|
||||
}
|
||||
return [startDay];
|
||||
});
|
||||
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
|
||||
const [monthDay, setMonthDay] = useState<number>(() => {
|
||||
const md = rule?.byMonthDay?.[0];
|
||||
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
|
||||
});
|
||||
const [nth, setNth] = useState<number>(() => {
|
||||
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
|
||||
return initialNthDay.nth;
|
||||
}
|
||||
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
|
||||
});
|
||||
const [nthDay, setNthDay] = useState<string>(() =>
|
||||
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
|
||||
);
|
||||
const [month, setMonth] = useState<number>(() => {
|
||||
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
|
||||
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
|
||||
});
|
||||
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
|
||||
const [untilDate, setUntilDate] = useState<string>(() => {
|
||||
if (rule?.until) {
|
||||
const d = new Date(rule.until);
|
||||
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
|
||||
}
|
||||
return format(addYears(eventStart, 1), "yyyy-MM-dd");
|
||||
});
|
||||
const [count, setCount] = useState<number>(rule?.count ?? 12);
|
||||
|
||||
const toggleWeekDay = (day: string) => {
|
||||
setWeekDays((prev) =>
|
||||
prev.includes(day)
|
||||
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
|
||||
: [...prev, day]
|
||||
);
|
||||
};
|
||||
|
||||
const buildRule = (): CalendarRecurrenceRule => {
|
||||
const built: CalendarRecurrenceRule = {
|
||||
"@type": "RecurrenceRule",
|
||||
frequency,
|
||||
interval: Math.max(1, interval),
|
||||
rscale: "gregorian",
|
||||
skip: "omit",
|
||||
firstDayOfWeek: "mo",
|
||||
byDay: null,
|
||||
byMonthDay: null,
|
||||
byMonth: null,
|
||||
byYearDay: null,
|
||||
byWeekNo: null,
|
||||
byHour: null,
|
||||
byMinute: null,
|
||||
bySecond: null,
|
||||
bySetPosition: null,
|
||||
count: endsMode === "after" ? Math.max(1, count) : null,
|
||||
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
|
||||
};
|
||||
|
||||
if (frequency === "weekly") {
|
||||
const days = weekDays.length ? weekDays : [startDay];
|
||||
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
|
||||
} else if (frequency === "monthly" || frequency === "yearly") {
|
||||
if (monthlyMode === "nth") {
|
||||
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
|
||||
} else {
|
||||
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
|
||||
}
|
||||
if (frequency === "yearly") {
|
||||
built.byMonth = [String(month)];
|
||||
}
|
||||
}
|
||||
|
||||
return built;
|
||||
};
|
||||
|
||||
const handleSave = () => onSave(buildRule());
|
||||
|
||||
const summary = buildRecurrenceSummary(buildRule(), t, locale);
|
||||
|
||||
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="shrink-0">{t("recurrence.editor_every")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={interval}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
|
||||
}}
|
||||
className="w-16 shrink-0"
|
||||
aria-label={t("recurrence.editor_every")}
|
||||
/>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
|
||||
className={`${selectCls} flex-1 min-w-0`}
|
||||
aria-label={t("recurrence.title")}
|
||||
>
|
||||
{EDITOR_FREQUENCIES.map((f) => (
|
||||
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{frequency === "weekly" && (
|
||||
<div className="flex gap-1">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => toggleWeekDay(day)}
|
||||
title={weekdayName(day, locale)}
|
||||
aria-pressed={weekDays.includes(day)}
|
||||
className={
|
||||
weekDays.includes(day)
|
||||
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
|
||||
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
}
|
||||
>
|
||||
{weekdayName(day, locale, "short")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === "yearly" && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
|
||||
className={`${selectCls} flex-1 min-w-0`}
|
||||
aria-label={t("recurrence.editor_in")}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(frequency === "monthly" || frequency === "yearly") && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<select
|
||||
value={monthlyMode}
|
||||
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
|
||||
className={`${selectCls} shrink-0`}
|
||||
aria-label={t("recurrence.editor_repeats_on")}
|
||||
>
|
||||
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
|
||||
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
|
||||
</select>
|
||||
{monthlyMode === "day" ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={monthDay}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
|
||||
}}
|
||||
className="w-16 shrink-0"
|
||||
aria-label={t("recurrence.editor_on_day")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={nth}
|
||||
onChange={(e) => setNth(parseInt(e.target.value, 10))}
|
||||
className={`${selectCls} flex-1 min-w-0`}
|
||||
aria-label={t("recurrence.editor_on_the")}
|
||||
>
|
||||
{[1, 2, 3, 4, -1].map((n) => (
|
||||
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={nthDay}
|
||||
onChange={(e) => setNthDay(e.target.value)}
|
||||
className={`${selectCls} flex-1 min-w-0`}
|
||||
aria-label={t("recurrence.editor_on_the")}
|
||||
>
|
||||
{WEEKDAYS.map((d) => (
|
||||
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
|
||||
<select
|
||||
value={endsMode}
|
||||
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
|
||||
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
|
||||
aria-label={t("recurrence.editor_ends")}
|
||||
>
|
||||
<option value="never">{t("recurrence.editor_never")}</option>
|
||||
<option value="on">{t("recurrence.until")}</option>
|
||||
<option value="after">{t("recurrence.editor_ends_after")}</option>
|
||||
</select>
|
||||
{endsMode === "on" && (
|
||||
<input
|
||||
type="date"
|
||||
value={untilDate}
|
||||
onChange={(e) => setUntilDate(e.target.value)}
|
||||
className={`${selectCls} flex-1 min-w-0`}
|
||||
aria-label={t("recurrence.editor_ends_on")}
|
||||
/>
|
||||
)}
|
||||
{endsMode === "after" && (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={count}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
|
||||
}}
|
||||
className="w-16 shrink-0"
|
||||
aria-label={t("recurrence.editor_ends_after")}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
|
||||
{summary}
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactsSidebar } from '../contacts-sidebar';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], activeAccountId: null };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
const group = {
|
||||
id: 'g1',
|
||||
kind: 'group',
|
||||
name: { full: 'Team' },
|
||||
members: { '1': true },
|
||||
} as unknown as ContactCard;
|
||||
|
||||
function renderSidebar(onComposeGroup = vi.fn()) {
|
||||
render(
|
||||
<ContactsSidebar
|
||||
groups={[group]}
|
||||
individuals={[]}
|
||||
addressBooks={[]}
|
||||
activeCategory="all"
|
||||
onSelectCategory={vi.fn()}
|
||||
onCreateGroup={vi.fn()}
|
||||
onCreateContact={vi.fn()}
|
||||
onEditGroup={vi.fn()}
|
||||
onDeleteGroup={vi.fn()}
|
||||
onComposeGroup={onComposeGroup}
|
||||
/>,
|
||||
);
|
||||
return onComposeGroup;
|
||||
}
|
||||
|
||||
describe('ContactsSidebar — compose to group', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows a "Send email to group" submenu in the group context menu', () => {
|
||||
renderSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Team'));
|
||||
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
|
||||
// and the existing Edit/Delete entries still render
|
||||
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('form.delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
|
||||
const onComposeGroup = renderSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Team'));
|
||||
|
||||
// Open the submenu (hover) then click "Cc".
|
||||
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
|
||||
fireEvent.mouseOver(trigger);
|
||||
fireEvent.mouseEnter(trigger);
|
||||
|
||||
fireEvent.click(screen.getByText('groups.send_email_cc'));
|
||||
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
|
||||
});
|
||||
});
|
||||
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
|
||||
const handleOpenEmail = (email: Email) => {
|
||||
selectEmail(email);
|
||||
router.push("/mail");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleOpenEvent = (event: CalendarEvent) => {
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
@@ -32,6 +29,8 @@ interface ContactDetailProps {
|
||||
onDelete: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||
onCompose?: () => void;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!contact) return;
|
||||
let cancelled = false;
|
||||
const parseCerts = async () => {
|
||||
const results = new Map<number, CertificateInfo>();
|
||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
||||
const key = cryptoKeys[i];
|
||||
if (typeof key.uri !== 'string') continue;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
const cert = parseCertificatePemOrDer(derBytes);
|
||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
if (!cancelled) results.set(i, info);
|
||||
} catch { /* skip unparseable keys */ }
|
||||
}
|
||||
if (!cancelled) setParsedCerts(results);
|
||||
};
|
||||
if (cryptoKeys.length > 0) {
|
||||
parseCerts();
|
||||
} else {
|
||||
setParsedCerts(new Map());
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
@@ -201,35 +160,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||
const jobTitles = titles.filter(t => t.kind !== "role");
|
||||
const roles = titles.filter(t => t.kind === "role");
|
||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||
|
||||
const handleImportContactCert = async (keyIndex: number) => {
|
||||
const key = cryptoKeys[keyIndex];
|
||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
||||
toast.success(t("detail.cert_imported"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
||||
}
|
||||
};
|
||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||
@@ -260,14 +194,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||
{email && (
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
{email && onCompose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCompose}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
{t("detail.compose_email")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{phone && (
|
||||
<a
|
||||
@@ -490,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section title={t("detail.crypto_keys")}>
|
||||
<div className="space-y-3">
|
||||
{cryptoKeys.map((key, i) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
||||
<p>
|
||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
||||
</p>
|
||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||
</div>
|
||||
{!alreadyImported && (
|
||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
)}
|
||||
{alreadyImported && (
|
||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
||||
)}
|
||||
</>
|
||||
{cryptoKeys.map((key, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
|
||||
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
|
||||
onDelete: () => void;
|
||||
onRemoveMember: (memberId: string) => void;
|
||||
onSelectMember: (id: string) => void;
|
||||
/** Compose an email to every member with the recipients placed in `field`. */
|
||||
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
|
||||
onDelete,
|
||||
onRemoveMember,
|
||||
onSelectMember,
|
||||
onComposeGroup,
|
||||
isMobile,
|
||||
className,
|
||||
}: ContactGroupDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const groupName = getContactDisplayName(group);
|
||||
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{onComposeGroup && hasEmailMembers && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
|
||||
<div className="inline-flex gap-1">
|
||||
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||
<Button
|
||||
key={field}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onComposeGroup(field)}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
{t(`groups.send_email_${field}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
|
||||
onSelectCategory: (category: ContactCategory) => void;
|
||||
onCreateGroup: () => void;
|
||||
onCreateContact: () => void;
|
||||
onCreateAddressBook?: () => void;
|
||||
onImport?: () => void;
|
||||
onEditGroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
@@ -90,9 +92,11 @@ export function ContactsSidebar({
|
||||
onSelectCategory,
|
||||
onCreateGroup,
|
||||
onCreateContact,
|
||||
onCreateAddressBook,
|
||||
onImport,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onComposeGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
@@ -296,6 +300,15 @@ export function ContactsSidebar({
|
||||
<UsersRound className="w-4 h-4" />
|
||||
{t("groups.create")}
|
||||
</button>
|
||||
{onCreateAddressBook && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||
>
|
||||
<BookPlus className="w-4 h-4" />
|
||||
{t("address_books.create")}
|
||||
</button>
|
||||
)}
|
||||
{onImport && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
@@ -684,6 +697,21 @@ export function ContactsSidebar({
|
||||
onEditGroup?.(groupContextMenu.data!.id);
|
||||
}}
|
||||
/>
|
||||
{onComposeGroup && (
|
||||
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
|
||||
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||
<ContextMenuItem
|
||||
key={field}
|
||||
label={t(`groups.send_email_${field}`)}
|
||||
onClick={() => {
|
||||
const groupId = groupContextMenu.data!.id;
|
||||
closeGroupContextMenu();
|
||||
onComposeGroup(groupId, field);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactSidebarPanel } from '../email-viewer';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const contact: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
name: {
|
||||
components: [
|
||||
{ kind: 'given', value: 'Alice' },
|
||||
{ kind: 'surname', value: 'Smith' },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
|
||||
const unknownEmail = 'unknown@example.com';
|
||||
|
||||
describe('ContactSidebarPanel', () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Edit button when contact is known and onEditContact is provided', () => {
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={vi.fn()}
|
||||
onEditContact={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// useTranslations mock returns the key, so we look for the common.edit key
|
||||
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
|
||||
expect(screen.getByText('edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEditContact when Edit button is clicked', () => {
|
||||
const onEditContact = vi.fn();
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={vi.fn()}
|
||||
onEditContact={onEditContact}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
|
||||
expect(onEditContact).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not show Edit button when contact is null', () => {
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email={unknownEmail}
|
||||
contact={null}
|
||||
onClose={vi.fn()}
|
||||
onEditContact={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Edit button when onEditContact is not provided', () => {
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "not in contacts" message and Add button for unknown email', () => {
|
||||
const onAddToContacts = vi.fn();
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email={unknownEmail}
|
||||
contact={null}
|
||||
onClose={vi.fn()}
|
||||
onAddToContacts={onAddToContacts}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
|
||||
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show badge when keyword id not in settings', () => {
|
||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
|
||||
// Unknown tags fall back to the raw id as label with a gray dot
|
||||
// (see email-list-item.tsx: keywordDefs fallback).
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows custom keyword label', () => {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// ─── Heavy component mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
|
||||
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ ref: { current: null } }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted, so all values must be defined inline.
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = { identities: [], defaultIdentityId: null };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
|
||||
|
||||
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
|
||||
class MockDataTransfer {
|
||||
private _data: Record<string, string> = {};
|
||||
types: string[] = [];
|
||||
effectAllowed = '';
|
||||
dropEffect = '';
|
||||
|
||||
setData(type: string, data: string) {
|
||||
this._data[type] = data;
|
||||
if (!this.types.includes(type)) this.types.push(type);
|
||||
}
|
||||
|
||||
getData(type: string): string {
|
||||
return this._data[type] ?? '';
|
||||
}
|
||||
|
||||
setDragImage(_image: Element, _x: number, _y: number) {
|
||||
// no-op: jsdom has no rendering, but the chip drag handler calls this.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_DATA = {
|
||||
to: 'alice@example.com, ',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
showCc: true,
|
||||
showBcc: true,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RecipientChipInput drag and drop', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('renders recipient chips with draggable="true"', async () => {
|
||||
render(<EmailComposer initialData={BASE_DATA} />);
|
||||
|
||||
const chipText = await screen.findByText('alice@example.com');
|
||||
const chipSpan = chipText.closest('[draggable]');
|
||||
expect(chipSpan).not.toBeNull();
|
||||
expect(chipSpan).toHaveAttribute('draggable', 'true');
|
||||
});
|
||||
|
||||
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
|
||||
render(<EmailComposer initialData={BASE_DATA} />);
|
||||
|
||||
const chipText = await screen.findByText('alice@example.com');
|
||||
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
|
||||
});
|
||||
|
||||
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
|
||||
|
||||
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
|
||||
const chip = await screen.findByText('Doo, John (john@doo.org)');
|
||||
const chipSpan = chip.closest('[draggable]') as HTMLElement;
|
||||
expect(chipSpan).not.toBeNull();
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
|
||||
});
|
||||
|
||||
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||
render(<EmailComposer initialData={BASE_DATA} />);
|
||||
|
||||
const chipText = await screen.findByText('alice@example.com');
|
||||
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
|
||||
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
|
||||
expect(chipSpan.className).toContain('opacity-50');
|
||||
|
||||
fireEvent.dragEnd(chipSpan);
|
||||
expect(chipSpan.className).not.toContain('opacity-50');
|
||||
});
|
||||
|
||||
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
|
||||
render(<EmailComposer initialData={BASE_DATA} />);
|
||||
|
||||
await screen.findByText('alice@example.com');
|
||||
|
||||
// The flex-wrap containers are the actual drop zones
|
||||
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||
// To-container has the draggable chip; cc-container doesn't
|
||||
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
|
||||
|
||||
if (!ccContainer) return;
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||
|
||||
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
|
||||
expect(ccContainer.className).toContain('ring-primary');
|
||||
|
||||
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
|
||||
expect(ccContainer.className).not.toContain('ring-primary');
|
||||
});
|
||||
|
||||
it('drop on a different field container moves the chip', async () => {
|
||||
render(<EmailComposer initialData={BASE_DATA} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
|
||||
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
|
||||
|
||||
if (!toContainer || !ccContainer) return;
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
|
||||
act(() => {
|
||||
fireEvent.drop(ccContainer, { dataTransfer: dt });
|
||||
});
|
||||
|
||||
// Chip should still appear exactly once (moved, not duplicated or lost)
|
||||
await screen.findByText('alice@example.com');
|
||||
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
|
||||
|
||||
// The To container must now be empty
|
||||
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drop on the same field container is a no-op', async () => {
|
||||
render(<EmailComposer initialData={BASE_DATA} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
|
||||
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||
fireEvent.dragOver(toContainer, { dataTransfer: dt });
|
||||
act(() => {
|
||||
fireEvent.drop(toContainer, { dataTransfer: dt });
|
||||
});
|
||||
|
||||
// Chip stays present exactly once
|
||||
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
|
||||
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
|
||||
const ccButton = screen.getByRole('button', { name: 'Cc' });
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||
fireEvent.dragOver(ccButton, { dataTransfer: dt });
|
||||
act(() => {
|
||||
fireEvent.drop(ccButton, { dataTransfer: dt });
|
||||
});
|
||||
|
||||
// cc_label is rendered by the mock translation as its key string
|
||||
const ccLabel = await screen.findByText('cc_label');
|
||||
expect(ccLabel).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// ─── Heavy component mocks (mirrors recipient-chip-drag.test.tsx) ──────────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
|
||||
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ ref: { current: null } }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = { identities: [], defaultIdentityId: null };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_DATA = {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
showCc: true,
|
||||
showBcc: true,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
/** next-intl is mocked to return the key, so the To placeholder is "to_placeholder". */
|
||||
const toInput = () => screen.getByPlaceholderText('to_placeholder') as HTMLInputElement;
|
||||
const ccInput = () => screen.getByPlaceholderText('cc_placeholder') as HTMLInputElement;
|
||||
|
||||
const paste = (input: HTMLElement, text: string) =>
|
||||
fireEvent.paste(input, { clipboardData: { getData: () => text } });
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RecipientChipInput paste', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('splits a pasted list (comma / semicolon / whitespace) into one chip per address', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput(); // capture once — placeholder disappears once chips exist
|
||||
paste(input, 'a@x.com b@y.com; c@z.com');
|
||||
|
||||
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||
// all consumed → input cleared
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('chips the valid addresses and leaves invalid text in the input', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'foo bar a@x.com');
|
||||
|
||||
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('foo bar');
|
||||
});
|
||||
|
||||
it('does not pre-empt a single-address paste (no delimiter → normal editing)', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
paste(toInput(), 'single@x.com');
|
||||
|
||||
// The handler bailed out, so no chip was created from the single token.
|
||||
expect(screen.queryByText('single@x.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps display names from a fully-quoted "Name <email>" list', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
|
||||
|
||||
// Chips render as "Name (email)" when a display name is present.
|
||||
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps a `Name <email>` pair as a single named chip', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'John Doe <j@x.com>, jane@y.com');
|
||||
|
||||
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps a comma inside a quoted display name intact', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
|
||||
|
||||
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('splits a newline-separated block (e.g. a spreadsheet column)', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'a@x.com\nb@y.com\nc@z.com');
|
||||
|
||||
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('dedupes case-insensitively within the paste and against existing chips', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput(); // DOM node persists across pastes (placeholder just clears)
|
||||
paste(input, 'a@x.com, A@X.com, b@y.com'); // within-paste dup collapses
|
||||
paste(input, 'A@X.COM, c@z.com'); // dup of an existing chip is dropped
|
||||
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||
// a@x.com appears exactly once despite three case variants across two pastes.
|
||||
expect(screen.getAllByText('a@x.com')).toHaveLength(1);
|
||||
expect(screen.queryByText('A@X.COM')).not.toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('chips the valid (named) entries and leaves a non-address token behind', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
|
||||
|
||||
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('works on the Cc field (shared handler)', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
paste(ccInput(), 'x@a.com; y@b.com');
|
||||
|
||||
expect(screen.getByText('x@a.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('y@b.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
|
||||
import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
|
||||
import { serializeEditorContent } from '../quoted-html';
|
||||
|
||||
describe('signature-block', () => {
|
||||
it('buildSignatureBlock wraps html in the marker div', () => {
|
||||
expect(buildSignatureBlock('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
|
||||
});
|
||||
|
||||
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
|
||||
const styled =
|
||||
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
|
||||
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
|
||||
'</tr></tbody></table>';
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, SignatureBlock],
|
||||
content: `<p>Hello</p>${buildSignatureBlock(styled)}`,
|
||||
});
|
||||
try {
|
||||
const out = serializeEditorContent(editor);
|
||||
// Original inline styling survives - it is NOT re-parsed into the schema.
|
||||
expect(out).toContain('background:#0a0e16');
|
||||
expect(out).toContain('border-radius:8px');
|
||||
expect(out).toContain('color:#c6f24e');
|
||||
expect(out).toContain(SIGNATURE_BLOCK_MARKER);
|
||||
// Surrounding body is preserved.
|
||||
expect(out).toContain('Hello');
|
||||
// The signature did not get the editor's generic table styling.
|
||||
expect(out).not.toContain('rgb(204, 204, 204)');
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const replyToForRsvp = parsedEvent.replyTo
|
||||
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
|
||||
|
||||
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|
||||
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|
||||
|| summary?.organizerEmail
|
||||
|| null;
|
||||
|
||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||
// Called after updating the local calendar event. Best-effort - if it
|
||||
// fails we still report the RSVP as sent since the calendar was updated.
|
||||
const sendImipReply = async () => {
|
||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.sendImipReply({
|
||||
organizerEmail,
|
||||
organizerName: summary?.organizer || undefined,
|
||||
attendeeEmail: currentUserEmail,
|
||||
attendeeName: myParticipant?.participant.name || undefined,
|
||||
uid: parsedEvent.uid,
|
||||
summary: parsedEvent.title,
|
||||
dtStart: parsedEvent.start || undefined,
|
||||
dtEnd: summary?.end || undefined,
|
||||
timeZone: parsedEvent.timeZone || undefined,
|
||||
isAllDay: parsedEvent.showWithoutTime || false,
|
||||
sequence: parsedEvent.sequence,
|
||||
status: imipStatus,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: don't block the RSVP success notification
|
||||
}
|
||||
};
|
||||
|
||||
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
|
||||
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
|
||||
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
|
||||
// here produced duplicate emails.
|
||||
try {
|
||||
const eventForRsvp = existingEvent
|
||||
? await resolveExistingEventForRsvp()
|
||||
@@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
);
|
||||
if (eventForRsvp && existingEventParticipant) {
|
||||
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
|
||||
await sendImipReply();
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
setState('parsed');
|
||||
@@ -615,7 +584,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
participants: repairedParticipants,
|
||||
replyTo: replyToForRsvp ?? undefined,
|
||||
}, true);
|
||||
await sendImipReply();
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
setState('parsed');
|
||||
@@ -632,7 +600,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
||||
if (newEvent && participant) {
|
||||
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
|
||||
await sendImipReply();
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
} else {
|
||||
|
||||
+1040
-450
File diff suppressed because it is too large
Load Diff
@@ -30,8 +30,11 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
|
||||
interface Position {
|
||||
@@ -63,6 +66,9 @@ interface EmailContextMenuProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: () => void;
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -133,8 +139,12 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -143,6 +153,8 @@ export function EmailContextMenu({
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -196,8 +208,36 @@ export function EmailContextMenu({
|
||||
</ContextMenuHeader>
|
||||
)}
|
||||
|
||||
{isScheduled && !showBatchActions && canCancelScheduled && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={CalendarClock}
|
||||
label={t("reschedule_send")}
|
||||
onClick={() => handleAction(onRescheduleScheduled!)}
|
||||
disabled={!onRescheduleScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={XCircle}
|
||||
label={t("cancel_scheduled_send")}
|
||||
onClick={() => handleAction(onCancelScheduled!)}
|
||||
disabled={!onCancelScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
|
||||
onClick={() => handleAction(onCancelScheduledForEdit!)}
|
||||
disabled={!onCancelScheduledForEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCancelScheduled && <ContextMenuSeparator />}
|
||||
|
||||
{!isScheduled && (
|
||||
<>
|
||||
|
||||
{/* Edit Draft - only for single draft emails */}
|
||||
{!showBatchActions && isDraft && onEditDraft && (
|
||||
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
@@ -209,7 +249,7 @@ export function EmailContextMenu({
|
||||
)}
|
||||
|
||||
{/* Single email actions - Reply, Reply All, Forward */}
|
||||
{!showBatchActions && (
|
||||
{!isScheduled && !showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={Reply}
|
||||
@@ -264,12 +304,13 @@ export function EmailContextMenu({
|
||||
return nodes.map((node) => {
|
||||
const Icon = getMailboxIcon(node.role);
|
||||
const isTarget = moveTargetIds.has(node.id);
|
||||
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
return (
|
||||
<div key={node.id}>
|
||||
{isTarget ? (
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={node.name}
|
||||
label={nodeLabel}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
@@ -281,7 +322,7 @@ export function EmailContextMenu({
|
||||
) : (
|
||||
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{node.name}</span>
|
||||
<span>{nodeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
@@ -375,6 +416,8 @@ export function EmailContextMenu({
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</ContextMenu>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { HoverAction } from "@/stores/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
|
||||
@@ -17,6 +17,10 @@ interface EmailHoverActionsProps {
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||
// the spam quick-action flips to "not spam".
|
||||
isInJunk?: boolean;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<HoverAction, {
|
||||
@@ -72,6 +76,8 @@ export function EmailHoverActions({
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
}: EmailHoverActionsProps) {
|
||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||
@@ -106,7 +112,8 @@ export function EmailHoverActions({
|
||||
onSetColorTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
onMarkAsSpam?.();
|
||||
if (isInJunk) onUndoSpam?.();
|
||||
else onMarkAsSpam?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -116,20 +123,28 @@ export function EmailHoverActions({
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
|
||||
// In a junk context the spam action becomes "not spam".
|
||||
const isNotSpam = actionId === "spam" && isInJunk;
|
||||
const DisplayIcon = actionId === "markRead"
|
||||
? (isUnread ? MailOpen : Mail)
|
||||
: actionId === "star" && isStarred
|
||||
? Star
|
||||
: Icon;
|
||||
: isNotSpam
|
||||
? ShieldCheck
|
||||
: Icon;
|
||||
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
|
||||
const className = isNotSpam
|
||||
? "hover:text-green-600 dark:hover:text-green-400"
|
||||
: config.className;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={actionId}
|
||||
onClick={(e) => handleAction(e, actionId)}
|
||||
title={t(config.titleKey)}
|
||||
title={title}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DisplayIcon
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||
import { isAuthenticationSpoofed } from '@/lib/email-headers';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
interface EmailIdentityBadgeProps {
|
||||
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
|
||||
// Parse the from address to check for sub-addressing
|
||||
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||
|
||||
// Find matching identity (email sent BY the user)
|
||||
const matchingIdentity = identities.find(
|
||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||
);
|
||||
// Find matching identity (email sent BY the user). When the message is
|
||||
// likely spoofed, the From address can't be trusted, so we ignore any
|
||||
// identity match — otherwise a forged From matching one of the user's own
|
||||
// addresses would render a misleading "via <identity>" badge that implies
|
||||
// legitimacy.
|
||||
const spoofed = isAuthenticationSpoofed(email.authenticationResults);
|
||||
const matchingIdentity = spoofed
|
||||
? undefined
|
||||
: identities.find(
|
||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||
);
|
||||
|
||||
// Check if email was sent TO a sub-address (received email)
|
||||
let receivedToTag: string | null = null;
|
||||
|
||||
@@ -29,11 +29,12 @@ interface EmailListItemProps {
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -46,11 +47,16 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
@@ -68,8 +74,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -162,7 +166,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
@@ -191,13 +195,13 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,6 +325,8 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -26,6 +27,8 @@ interface EmailListProps {
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
hasMore?: boolean;
|
||||
isLoadingMoreItems?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
@@ -39,6 +42,11 @@ interface EmailListProps {
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
onEditDraft?: (email: Email) => void;
|
||||
isScheduledView?: boolean;
|
||||
onLoadMoreScheduled?: () => void;
|
||||
onCancelScheduled?: (email: Email) => void | Promise<void>;
|
||||
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
||||
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
@@ -48,6 +56,8 @@ export function EmailList({
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
hasMore,
|
||||
isLoadingMoreItems,
|
||||
onOpenConversation,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
@@ -61,6 +71,11 @@ export function EmailList({
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
onEditDraft,
|
||||
isScheduledView = false,
|
||||
onLoadMoreScheduled,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailListProps) {
|
||||
const t = useTranslations('email_list');
|
||||
const { client } = useAuthStore();
|
||||
@@ -85,19 +100,32 @@ export function EmailList({
|
||||
isLoadingThread,
|
||||
toggleThreadExpansion,
|
||||
fetchThreadEmails,
|
||||
markThreadAsRead,
|
||||
collapseAllThreads,
|
||||
threadEmailCounts,
|
||||
searchFilters,
|
||||
setSearchFilters,
|
||||
clearSearchFilters,
|
||||
advancedSearch,
|
||||
searchQuery,
|
||||
isUnifiedView,
|
||||
unifiedRole,
|
||||
} = useEmailStore();
|
||||
|
||||
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
|
||||
// there is no concrete mailbox to read the role from. Fall back to the unified
|
||||
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
|
||||
// that role's folder.
|
||||
const effectiveMailboxRole =
|
||||
mailboxes.find(m => m.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
|
||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails, disableThreading);
|
||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails, disableThreading]);
|
||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -107,7 +135,11 @@ export function EmailList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
@@ -216,10 +248,14 @@ export function EmailList({
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isScheduledView) {
|
||||
onLoadMoreScheduled?.();
|
||||
return;
|
||||
}
|
||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||
loadMoreEmails(client);
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||
|
||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadId);
|
||||
@@ -227,10 +263,12 @@ export function EmailList({
|
||||
if (!isExpanded && client) {
|
||||
toggleThreadExpansion(threadId);
|
||||
await fetchThreadEmails(client, threadId);
|
||||
// Mark all unread emails in this thread as read
|
||||
void markThreadAsRead(client, threadId);
|
||||
} else {
|
||||
toggleThreadExpansion(threadId);
|
||||
}
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
|
||||
|
||||
// Range-based load more: trigger when last visible item is near the end.
|
||||
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||
@@ -279,7 +317,7 @@ export function EmailList({
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
@@ -403,17 +441,19 @@ export function EmailList({
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
{isScheduledView ? (
|
||||
<CalendarClock className="w-10 h-10 text-muted-foreground" />
|
||||
) : searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||
) : (
|
||||
<MailX className="w-10 h-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-muted-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -448,7 +488,18 @@ export function EmailList({
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onCollapseAllThreads={collapseAllThreads}
|
||||
onEmailSelect={(email) => {
|
||||
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||
if (currentExpanded.size > 0) {
|
||||
// Check if the selected email belongs to any expanded thread via its threadId
|
||||
if (!email.threadId || !currentExpanded.has(email.threadId)) {
|
||||
collapseAllThreads();
|
||||
}
|
||||
}
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@@ -458,6 +509,7 @@ export function EmailList({
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -465,13 +517,13 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
<div className="py-4 flex justify-center">
|
||||
{isLoadingMore && hasMoreEmails && (
|
||||
{footerIsLoadingMore && footerHasMore && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t('loading_more')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMoreEmails && emails.length > 0 && (
|
||||
{!footerHasMore && emails.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
||||
{t('no_more_emails')}
|
||||
</div>
|
||||
@@ -491,7 +543,7 @@ export function EmailList({
|
||||
menuRef={menuRef}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
currentMailboxRole={effectiveMailboxRole}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
@@ -506,6 +558,9 @@ export function EmailList({
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
|
||||
+751
-1271
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (node.type.name === "signatureBlock") {
|
||||
// Same rationale as quotedHtml: inline the verbatim signature HTML so the
|
||||
// styled signature reaches the recipient (and a saved draft round-trips)
|
||||
// instead of the schema-flattened version.
|
||||
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
|
||||
return;
|
||||
}
|
||||
const fragment = serializer.serializeNode(node);
|
||||
const tmp = document.createElement("div");
|
||||
tmp.appendChild(fragment);
|
||||
parts.push(tmp.innerHTML);
|
||||
});
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the editor-content wrapper that the composer prepends/appends so the
|
||||
* quoted original becomes a single QuotedHtml node. The inner HTML must be
|
||||
* pre-sanitized (scripts/styles/head stripped, cid: images rewritten).
|
||||
*
|
||||
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
|
||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface ReadReceiptBannerProps {
|
||||
/** Address that requested the receipt (Disposition-Notification-To). */
|
||||
requestedBy: string;
|
||||
/** Sends the MDN. Should resolve when the receipt has been submitted. */
|
||||
onSend: () => Promise<void>;
|
||||
/** Suppresses the request without sending (sets $MDNSent server-side). */
|
||||
onIgnore: () => void;
|
||||
}
|
||||
|
||||
export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) {
|
||||
const t = useTranslations('email_viewer.read_receipt');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
if (state === 'sent') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{t('sent')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-foreground">{t('prompt')}</span>
|
||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setState('sending');
|
||||
try {
|
||||
await onSend();
|
||||
setState('sent');
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}}
|
||||
disabled={state === 'sending'}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{t('send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onIgnore}
|
||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{t('ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
parseHTML: (el) => {
|
||||
const dataCid = el.getAttribute("data-cid");
|
||||
if (dataCid) return dataCid;
|
||||
// Fall back to deriving the cid from `src="cid:xxx"` so inline
|
||||
// image refs survive editor round-trips even when data-cid was
|
||||
// never set (defensive — the composer normally pre-rewrites
|
||||
// quoted-body cid: refs into data-cid).
|
||||
const src = el.getAttribute("src") || "";
|
||||
if (/^cid:/i.test(src)) return src.slice(4) || null;
|
||||
return null;
|
||||
},
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Table } from "@tiptap/extension-table";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Bold,
|
||||
@@ -231,6 +233,13 @@ export function RichTextEditor({
|
||||
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
||||
},
|
||||
}),
|
||||
// Quoted/forwarded original email body - held verbatim as an atomic
|
||||
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
||||
QuotedHtml,
|
||||
// Identity signature - held verbatim as a non-editable atomic node so
|
||||
// rich/branded signatures keep their inline styling in the editor and
|
||||
// in the sent mail (see signature-block.ts).
|
||||
SignatureBlock,
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
@@ -281,14 +290,18 @@ export function RichTextEditor({
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
// serializeEditorContent (not getHTML) so the verbatim quoted-original
|
||||
// HTML held in the QuotedHtml atom node is emitted intact.
|
||||
onChange(serializeEditorContent(editor));
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
// Sync external content changes (e.g. template application)
|
||||
// Sync external content changes (e.g. template application). Compare against
|
||||
// the custom serialization so a doc that only differs inside a QuotedHtml
|
||||
// island isn't needlessly re-parsed (which would reset the island DOM).
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
if (editor && content !== serializeEditorContent(editor)) {
|
||||
editor.commands.setContent(content, { emitUpdate: false });
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
// Marker attribute that identifies the signature wrapper in serialized HTML,
|
||||
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||
|
||||
/**
|
||||
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||
*
|
||||
* Why: the signature is embedded into the composer so it stays in the body,
|
||||
* but parsing rich, table-based "brand" signatures into the ProseMirror schema
|
||||
* strips their inline CSS (background/text colors, fonts, border-radius). By
|
||||
* holding the signature as an atom it is never parsed into the schema, so the
|
||||
* styling survives 1:1 — both in the editor (rendered by the NodeView below)
|
||||
* and in the sent mail (emitted by serializeEditorContent in quoted-html.ts).
|
||||
*
|
||||
* Mirrors QuotedHtml, but the inner region is read-only: a signature is meant
|
||||
* to be inserted/removed as a unit, not edited inline. Select the node and
|
||||
* press Backspace/Delete to drop the whole signature.
|
||||
*/
|
||||
export const SignatureBlock = TiptapNode.create({
|
||||
name: "signatureBlock",
|
||||
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 the rich
|
||||
// signature markup never hits (and is never mangled by) the schema.
|
||||
parseHTML: (el) => el.innerHTML,
|
||||
// The real content round-trips via the custom serializer
|
||||
// (serializeEditorContent); renderHTML below only needs the wrapper.
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `div[${SIGNATURE_BLOCK_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, { [SIGNATURE_BLOCK_MARKER]: "" })];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||
dom.className = "signature-block-island";
|
||||
|
||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||
// would otherwise cascade INTO the signature and destroy its layout -
|
||||
// exactly the corruption we are fixing. Shadow DOM isolates both
|
||||
// directions, so only the browser's UA defaults + the signature's own
|
||||
// inline styles apply and the in-editor preview matches the sent mail.
|
||||
const shadow = dom.attachShadow({ mode: "open" });
|
||||
const inner = document.createElement("div");
|
||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||
inner.contentEditable = "false";
|
||||
inner.innerHTML = node.attrs.html || "";
|
||||
shadow.appendChild(inner);
|
||||
|
||||
return {
|
||||
dom,
|
||||
// ProseMirror must not try to reconcile the foreign shadow content.
|
||||
ignoreMutation: () => true,
|
||||
// Let ProseMirror handle all events so clicking selects the atom and
|
||||
// Backspace/Delete removes the whole signature.
|
||||
stopEvent: () => false,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
||||
inner.innerHTML = updatedNode.attrs.html || "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the editor-content wrapper that embeds the signature as a single
|
||||
* SignatureBlock node. The inner HTML must be pre-sanitized
|
||||
* (sanitizeSignatureHtml). The `data-signature-block-node` marker is what
|
||||
* parseHTML keys on, so this exact form must be what serializeEditorContent
|
||||
* emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildSignatureBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${SIGNATURE_BLOCK_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SmimeStatus } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeStatusBannerProps {
|
||||
status: SmimeStatus;
|
||||
onUnlockKey?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
const variantTone: Record<SmimeVariant, string> = {
|
||||
success: 'bg-success/15 text-success',
|
||||
warning: 'bg-warning/15 text-warning',
|
||||
error: 'bg-destructive/15 text-destructive',
|
||||
info: 'bg-info/15 text-info',
|
||||
};
|
||||
|
||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
||||
const t = useTranslations('smime');
|
||||
|
||||
const items: Array<{
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
variant: SmimeVariant;
|
||||
}> = [];
|
||||
|
||||
// Encryption status
|
||||
if (status.isEncrypted) {
|
||||
if (status.decryptionError) {
|
||||
if (status.decryptionError === 'locked') {
|
||||
items.push({
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.decryptionError === 'no-key') {
|
||||
items.push({
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
text: t('status_encrypted_no_key'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-5 h-5" />,
|
||||
text: t('status_encrypted_failed'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({
|
||||
icon: <LockOpen className="w-5 h-5" />,
|
||||
text: t('status_encrypted_ok'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
if (status.signatureValid === true) {
|
||||
if (status.selfSigned) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
text: t('status_signed_self_signed'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.signerEmailMatch === false) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldCheck className="w-5 h-5" />,
|
||||
text: t('status_signed_valid'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
} else if (status.signatureValid === false) {
|
||||
items.push({
|
||||
icon: <ShieldAlert className="w-5 h-5" />,
|
||||
text: status.signatureError || t('status_signed_invalid'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported S/MIME
|
||||
if (status.unsupportedReason) {
|
||||
items.push({
|
||||
icon: <Info className="w-5 h-5" />,
|
||||
text: t('status_unsupported'),
|
||||
variant: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 py-1", className)}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
variantTone[item.variant],
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
S/MIME
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{item.text}
|
||||
</div>
|
||||
</div>
|
||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
||||
<button
|
||||
onClick={onUnlockKey}
|
||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
|
||||
>
|
||||
{t('unlock_key')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -17,6 +17,23 @@ import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Small chip showing the originating folder of a message, rendered in the
|
||||
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
|
||||
* from different folders. `email.sourceFolder` is stamped at fetch time.
|
||||
*/
|
||||
function SourceFolderTag({ name }: { name: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground"
|
||||
title={name}
|
||||
>
|
||||
<Folder className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
@@ -24,6 +41,7 @@ interface ThreadListItemProps {
|
||||
isLoading?: boolean;
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onCollapseAllThreads?: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
@@ -34,6 +52,7 @@ interface ThreadListItemProps {
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
}
|
||||
|
||||
interface SingleEmailItemProps {
|
||||
@@ -50,32 +69,43 @@ interface SingleEmailItemProps {
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
|
||||
// to the unified role so junk-contextual UI and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
// Show the originating folder in the aggregate "All …" views.
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
|
||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
@@ -91,8 +121,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -183,7 +211,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
@@ -217,13 +245,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,12 +269,23 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -299,14 +338,25 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -335,16 +385,20 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +412,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
isLoading = false,
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onCollapseAllThreads,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onContextMenu,
|
||||
@@ -368,24 +423,34 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
|
||||
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
// "me"). Aggregate role-views use a virtual selected mailbox → fall back to
|
||||
// the unified role so junk-contextual UI and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const displayNames = showRecipient
|
||||
? Array.from(new Set(
|
||||
@@ -439,6 +504,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -484,6 +550,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
} else {
|
||||
if (selectedEmailIds.size > 0) clearSelection();
|
||||
if (!isExpanded) {
|
||||
onCollapseAllThreads?.();
|
||||
onToggleExpand();
|
||||
}
|
||||
onEmailSelect(latestEmail);
|
||||
@@ -550,46 +617,49 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t('toggle_thread')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full",
|
||||
"transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"bg-background border border-border"
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t('toggle_thread')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -621,13 +691,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{latestEmail.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -645,12 +715,23 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -715,14 +796,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{scheduledSendLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -751,16 +843,20 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Paperclip, Download } from "lucide-react";
|
||||
import { sanitizeEmailHtmlForIframe } from "@/lib/email-sanitization";
|
||||
|
||||
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
|
||||
// this preview renders are typed.
|
||||
export type ParsedEml = {
|
||||
subject?: string;
|
||||
from?: { name?: string; address?: string };
|
||||
to?: Array<{ name?: string; address?: string }>;
|
||||
date?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments?: Array<{ filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }>;
|
||||
};
|
||||
|
||||
function formatAddress(a?: { name?: string; address?: string }): string {
|
||||
if (!a) return "";
|
||||
if (a.name && a.address) return `${a.name} <${a.address}>`;
|
||||
return a.address || a.name || "";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Renders a .eml attachment like an email: header (from/to/subject/date) + the
|
||||
// body, plus the message's own attachments. The body is sanitized with
|
||||
// DOMPurify AND rendered in a fully-locked sandbox iframe (sandbox="" - no
|
||||
// scripts, no same-origin), so a script-bearing .eml can never execute in our
|
||||
// origin. Parsing happens in the caller (FilePreviewModal); this is pure
|
||||
// presentation.
|
||||
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
|
||||
const bodyDoc = message.html
|
||||
? sanitizeEmailHtmlForIframe(message.html)
|
||||
: message.text
|
||||
? `<pre style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
|
||||
: "";
|
||||
|
||||
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[number]) => {
|
||||
if (!att.content) return;
|
||||
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
|
||||
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
|
||||
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = att.filename || "attachment";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-full self-start overflow-auto rounded-lg border border-border bg-background shadow-2xl p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{message.from && (
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||
)}
|
||||
{message.to && message.to.length > 0 && (
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
{bodyDoc && (
|
||||
<iframe
|
||||
title={message.subject || "email"}
|
||||
sandbox=""
|
||||
srcDoc={bodyDoc}
|
||||
className="w-full min-h-[55vh] mt-3 rounded bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => downloadAttachment(att)}
|
||||
title={t("download")}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
|
||||
>
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="max-w-[200px] truncate">{att.filename || "attachment"}</span>
|
||||
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu,
|
||||
Menu, Users, Share2,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +26,9 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
|
||||
type SortKey = "name" | "size" | "modified";
|
||||
type SortDir = "asc" | "desc";
|
||||
@@ -95,6 +98,14 @@ interface FileBrowserProps {
|
||||
accountPickerMode?: boolean;
|
||||
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
||||
accountLabel?: string | null;
|
||||
/** JMAP client for the browsing account, used by the share dialog to list principals. */
|
||||
client?: IJMAPClient | null;
|
||||
/** Files account id of the browsing account; used to exclude self from the share picker. */
|
||||
ownAccountId?: string | null;
|
||||
/** True when the server supports JMAP Sharing (principals); gates the Share action. */
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -230,6 +241,33 @@ function getGridIcon(resource: FileResource) {
|
||||
return getFileIconByName(resource.name, "lg");
|
||||
}
|
||||
|
||||
// Small inline indicator: a node shared out by the user (shareWith has entries)
|
||||
// or a node shared *with* the user by another principal (isShared).
|
||||
function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) {
|
||||
const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0;
|
||||
if (resource.isShared) {
|
||||
return (
|
||||
<Share2
|
||||
className="w-3.5 h-3.5 text-primary shrink-0"
|
||||
aria-label={t("shared_with_me")}
|
||||
>
|
||||
<title>{t("shared_with_me")}</title>
|
||||
</Share2>
|
||||
);
|
||||
}
|
||||
if (sharedOut) {
|
||||
return (
|
||||
<Users
|
||||
className="w-3.5 h-3.5 text-primary shrink-0"
|
||||
aria-label={t("shared")}
|
||||
>
|
||||
<title>{t("shared")}</title>
|
||||
</Users>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
|
||||
name: string;
|
||||
getImageUrl: (n: string) => Promise<string>;
|
||||
@@ -342,11 +380,25 @@ export function FileBrowser({
|
||||
onSelectAccount,
|
||||
accountPickerMode,
|
||||
accountLabel,
|
||||
client,
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
// The share dialog is bound to a node id (not name) so its shareWith stays
|
||||
// live after a share refresh re-derives the resource list.
|
||||
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
|
||||
// A node is shareable when the server supports JMAP Sharing, the viewer owns
|
||||
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
|
||||
// report full rights; treat missing myRights as allowed).
|
||||
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
|
||||
!!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)),
|
||||
[sharingEnabled, onShare, client]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
@@ -1440,8 +1492,9 @@ export function FileBrowser({
|
||||
{showThumbnails && isImageFile(resource.name)
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
||||
: getGridIcon(resource)}
|
||||
<span className="text-xs truncate w-full text-center" title={resource.name}>
|
||||
{resource.name}
|
||||
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
|
||||
<span className="truncate">{resource.name}</span>
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -1590,6 +1643,7 @@ export function FileBrowser({
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
||||
: getFileIcon(resource)}
|
||||
<span className="truncate">{resource.name}</span>
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||
@@ -1697,6 +1751,19 @@ export function FileBrowser({
|
||||
{t("duplicate")}
|
||||
</button>
|
||||
)}
|
||||
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
onClick={() => {
|
||||
const r = resources.find(res => res.name === contextMenu.name);
|
||||
if (r) setShareTargetId(r.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
{t("share")}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
@@ -1936,6 +2003,20 @@ export function FileBrowser({
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share dialog */}
|
||||
{shareTarget && client && onShare && (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="file"
|
||||
collectionName={shareTarget.name}
|
||||
shareWith={shareTarget.shareWith}
|
||||
ownAccountId={ownAccountId || ""}
|
||||
onShare={(principalId, rights) =>
|
||||
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
|
||||
onClose={() => setShareTargetId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
import { X, Download, Loader2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getFilePreviewKind } from "@/lib/file-preview";
|
||||
import { getFilePreviewKind, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
|
||||
|
||||
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
|
||||
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
|
||||
// desktop bundle.
|
||||
const PdfMobileViewer = dynamic(
|
||||
() => import("@/components/files/pdf-mobile-viewer").then((m) => m.PdfMobileViewer),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
// Map a few well-known extensions back to canonical MIME types. Used when the
|
||||
// server returns application/octet-stream (or empty) for an attachment whose
|
||||
// actual type is obvious from the filename. The blob.type drives how browsers
|
||||
// render blob: URLs, so guessing wrong here means the inline preview silently
|
||||
// downgrades to a download.
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
bmp: "image/bmp",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
ogv: "video/ogg",
|
||||
};
|
||||
|
||||
function inferMimeFromName(name: string): string | undefined {
|
||||
const ext = name.toLowerCase().split(".").pop();
|
||||
return ext ? EXT_TO_MIME[ext] : undefined;
|
||||
}
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
name: string;
|
||||
@@ -111,6 +149,54 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
|
||||
const [pdfInlineSupported, setPdfInlineSupported] = useState(true);
|
||||
// Whether the resolved blob MIME is inert enough to open as a top-level
|
||||
// navigation. Blob URLs inherit our origin, so opening a script-bearing type
|
||||
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
|
||||
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
|
||||
const [emlContent, setEmlContent] = useState<ParsedEml | null>(null);
|
||||
|
||||
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
||||
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
||||
// signal and correctly reports false on Android Chrome (no inline viewer).
|
||||
// iOS Safari is the exception: it reports true (it can show PDFs on top-frame
|
||||
// navigation) yet renders only the FIRST page inside an <iframe> - a
|
||||
// long-standing WebKit limitation - so it must use pdf.js too. Detect iOS
|
||||
// (incl. iPadOS, which spoofs a "Macintosh" UA but exposes touch points).
|
||||
useEffect(() => {
|
||||
const nav = navigator as Navigator & { pdfViewerEnabled?: boolean };
|
||||
const isIOS =
|
||||
/iPad|iPhone|iPod/.test(nav.userAgent) ||
|
||||
(nav.maxTouchPoints > 1 && /Macintosh/.test(nav.userAgent));
|
||||
if (isIOS) {
|
||||
setPdfInlineSupported(false);
|
||||
} else if (typeof nav.pdfViewerEnabled === "boolean") {
|
||||
setPdfInlineSupported(nav.pdfViewerEnabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep the latest onClose for the back-button handler without re-subscribing.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Make the Android/browser Back button close the preview instead of
|
||||
// navigating the page underneath: push a throwaway history entry when the
|
||||
// modal opens and close it on popstate. On a normal close (X / backdrop /
|
||||
// Escape) the modal unmounts and we pop that entry ourselves, so the user's
|
||||
// next Back isn't swallowed by it.
|
||||
useEffect(() => {
|
||||
window.history.pushState({ __filePreview: true }, "");
|
||||
let poppedByBack = false;
|
||||
const onPop = () => {
|
||||
poppedByBack = true;
|
||||
onCloseRef.current();
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPop);
|
||||
if (!poppedByBack) window.history.back();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fileType = resolvedFileType;
|
||||
|
||||
@@ -120,6 +206,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
|
||||
setContent(null);
|
||||
setObjectUrl(null);
|
||||
setCanOpenInNewTab(false);
|
||||
setEmlContent(null);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setResolvedFileType(getFilePreviewKind(name));
|
||||
@@ -136,9 +224,36 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
if (previewType === "text" || previewType === "markdown") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setContent(text);
|
||||
} else if (previewType === "eml") {
|
||||
// Parse the embedded message (postal-mime, dynamic-imported so it
|
||||
// stays off the bundle) and render it like an email via EmlPreview.
|
||||
const { default: PostalMime } = await import("postal-mime");
|
||||
const parsed = await new PostalMime().parse(await blob.arrayBuffer());
|
||||
if (!cancelled) setEmlContent(parsed as ParsedEml);
|
||||
} else {
|
||||
revokeUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setObjectUrl(revokeUrl);
|
||||
// Stalwart's download endpoint can return generic
|
||||
// application/octet-stream for attachments even when the email's
|
||||
// MIME structure declared application/pdf (etc.). The blob inherits
|
||||
// that, so a blob: URL plugged into <iframe> looks like a binary
|
||||
// stream and Chrome/Edge silently download it (with the blob UUID
|
||||
// as filename) instead of rendering inline. Re-wrap with the most
|
||||
// specific MIME we can resolve - prefer explicit attachment type,
|
||||
// then filename-derived MIME, then whatever the blob came with.
|
||||
const inferredFromName = inferMimeFromName(name);
|
||||
const isUseless = (ty?: string) =>
|
||||
!ty || ty === "application/octet-stream" || ty === "binary/octet-stream";
|
||||
const effectiveType =
|
||||
(contentType && !isUseless(contentType) ? contentType : undefined)
|
||||
?? inferredFromName
|
||||
?? blob.type;
|
||||
const typedBlob = blob.type !== effectiveType
|
||||
? new Blob([blob], { type: effectiveType })
|
||||
: blob;
|
||||
revokeUrl = URL.createObjectURL(typedBlob);
|
||||
if (!cancelled) {
|
||||
setObjectUrl(revokeUrl);
|
||||
setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
@@ -173,6 +288,18 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{objectUrl && canOpenInNewTab && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title={t("open_in_new_tab")}
|
||||
aria-label={t("open_in_new_tab")}
|
||||
onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -211,6 +338,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "eml" && emlContent && (
|
||||
<EmlPreview message={emlContent} />
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<img
|
||||
src={objectUrl}
|
||||
@@ -231,19 +362,29 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<object
|
||||
data={objectUrl}
|
||||
type="application/pdf"
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && (
|
||||
// <iframe> renders PDFs reliably across desktop Chromium, Firefox,
|
||||
// and Safari from a blob: URL. <object> was prone to falling back to
|
||||
// a silent download when the blob's Content-Type wasn't recognised.
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
aria-label={name}
|
||||
title={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && !pdfInlineSupported && (
|
||||
// Mobile browsers can't show a PDF inline in an <iframe> (Android: a
|
||||
// blank frame / silent download; iOS: only the first page), so render
|
||||
// it with pdf.js (canvas) instead. The header's open-in-new-tab /
|
||||
// download actions are the fallback if pdf.js can't render the doc.
|
||||
<div
|
||||
className="w-full max-w-3xl h-full overflow-auto rounded-lg bg-neutral-200 dark:bg-neutral-800 p-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
</object>
|
||||
<PdfMobileViewer url={objectUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "audio" && objectUrl && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Home,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
@@ -29,6 +30,8 @@ interface FolderTreeSidebarProps {
|
||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
|
||||
const t = useTranslations("files");
|
||||
const client = useFileStore(s => s.client);
|
||||
const sharedRoots = useFileStore(s => s.sharedRoots);
|
||||
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
|
||||
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||
@@ -81,6 +84,8 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
loadChildren(null, "/");
|
||||
// Discover folders shared with the user by other principals.
|
||||
loadSharedRoots();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client]);
|
||||
@@ -180,6 +185,40 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Shared with me: folders another principal has shared with the user */}
|
||||
{sharedRoots.filter(r => r.isDirectory).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
<span className="truncate">{t("shared_with_me")}</span>
|
||||
</div>
|
||||
{sharedRoots.filter(r => r.isDirectory).map(r => {
|
||||
const path = `/${r.name}`;
|
||||
const isSelected = currentPath === path;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick(path, r.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have
|
||||
// no native inline PDF viewer, so the desktop <iframe src=blob:> approach
|
||||
// renders a blank frame. pdf.js rasterises each page to a <canvas> in pure JS,
|
||||
// so it works everywhere regardless of native PDF support.
|
||||
//
|
||||
// This component is dynamic-imported (ssr:false) only on the mobile PDF path,
|
||||
// and it dynamic-imports pdfjs-dist itself, so the (~hundreds of KB) library +
|
||||
// worker never reach the desktop bundle.
|
||||
|
||||
// iOS WebKit caps total canvas area (~16 MP) and is memory-sensitive; keep each
|
||||
// page's backing store well under that so large pages don't render blank. The
|
||||
// backing store is rendered at ~2x the fit width (dpr), which also gives the
|
||||
// double-tap zoom some headroom before CSS upscaling softens the text.
|
||||
const MAX_CANVAS_AREA = 4_000_000; // ~4 MP per page
|
||||
|
||||
// Double-tap zoom steps: fit -> 2x -> 3x -> fit. Implemented as the page
|
||||
// container's CSS width (so panning is native scrolling, not a transform).
|
||||
const ZOOM_STEPS = [1, 2, 3];
|
||||
const MAX_ZOOM = 4; // pinch can go a bit beyond the double-tap steps
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const DOUBLE_TAP_SLOP = 30; // px
|
||||
|
||||
export function PdfMobileViewer({ url }: { url: string }) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pagesRef = useRef<HTMLDivElement>(null);
|
||||
const zoom = useRef({ step: 0, scale: 1 });
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const t = useTranslations("files");
|
||||
|
||||
// Render the PDF pages to canvases.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadingTask: import("pdfjs-dist").PDFDocumentLoadingTask | null = null;
|
||||
|
||||
// Reset zoom for a freshly loaded document.
|
||||
zoom.current = { step: 0, scale: 1 };
|
||||
if (pagesRef.current) pagesRef.current.style.width = "100%";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
loadingTask = pdfjs.getDocument({ url });
|
||||
const doc = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
|
||||
const pages = pagesRef.current;
|
||||
if (!pages) return;
|
||||
pages.replaceChildren();
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const cssWidth = Math.min(pages.clientWidth || 320, 900);
|
||||
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (cancelled) return;
|
||||
const page = await doc.getPage(n);
|
||||
const baseVp = page.getViewport({ scale: 1 });
|
||||
let scale = (cssWidth / baseVp.width) * dpr;
|
||||
const area = baseVp.width * scale * (baseVp.height * scale);
|
||||
if (area > MAX_CANVAS_AREA) scale *= Math.sqrt(MAX_CANVAS_AREA / area);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "auto";
|
||||
canvas.style.display = "block";
|
||||
canvas.style.margin = "0 auto 8px";
|
||||
canvas.style.background = "#fff";
|
||||
pages.appendChild(canvas);
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
}
|
||||
if (!cancelled) setStatus("ready");
|
||||
} catch {
|
||||
if (!cancelled) setStatus("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void loadingTask?.destroy().catch(() => {});
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
// Gesture zoom. 1-finger double-tap cycles the steps (fit -> 2x -> 3x ->
|
||||
// fit); 2-finger pinch zooms continuously up to MAX_ZOOM. Both drive a
|
||||
// per-page CSS-width zoom that pans via native scrolling and re-centre on the
|
||||
// gesture point. touch-action (pan-x pan-y) keeps 1-finger panning native
|
||||
// while disabling the browser's own pinch/double-tap page zoom, which we
|
||||
// replace here.
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const distance = (a: Touch, b: Touch) =>
|
||||
Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||
|
||||
// Apply an absolute zoom scale, keeping (cx, cy) [relative to root] under
|
||||
// the same content point.
|
||||
const applyZoom = (target: number, cx: number, cy: number) => {
|
||||
const pages = pagesRef.current;
|
||||
if (!pages) return;
|
||||
const next = Math.max(1, Math.min(MAX_ZOOM, target));
|
||||
const ratio = next / zoom.current.scale;
|
||||
if (ratio === 1) return;
|
||||
pages.style.width = `${next * 100}%`;
|
||||
void root.offsetWidth; // force reflow so the new scroll range is live
|
||||
root.scrollLeft = (root.scrollLeft + cx) * ratio - cx;
|
||||
root.scrollTop = (root.scrollTop + cy) * ratio - cy;
|
||||
zoom.current.scale = next;
|
||||
};
|
||||
|
||||
let lastTap = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let pinching = false;
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartScale = 1;
|
||||
let didPinch = false;
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
pinching = true;
|
||||
didPinch = true;
|
||||
pinchStartDist = distance(e.touches[0], e.touches[1]) || 1;
|
||||
pinchStartScale = zoom.current.scale;
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (!pinching || e.touches.length !== 2) return;
|
||||
e.preventDefault(); // take over the 2-finger gesture from native pan
|
||||
const rect = root.getBoundingClientRect();
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
applyZoom(pinchStartScale * (distance(e.touches[0], e.touches[1]) / pinchStartDist), cx, cy);
|
||||
};
|
||||
|
||||
const onEnd = (e: TouchEvent) => {
|
||||
if (pinching && e.touches.length < 2) {
|
||||
pinching = false;
|
||||
// Snap the step index to the current scale so double-tap stays sensible.
|
||||
const s = zoom.current.scale;
|
||||
zoom.current.step = s <= 1.01 ? 0 : s < 3 ? 1 : 2;
|
||||
}
|
||||
if (e.touches.length > 0) return; // fingers still down
|
||||
if (didPinch) {
|
||||
didPinch = false; // a pinch is not a tap
|
||||
return;
|
||||
}
|
||||
if (e.changedTouches.length !== 1) return;
|
||||
const touch = e.changedTouches[0];
|
||||
const now = e.timeStamp;
|
||||
const isDouble =
|
||||
now - lastTap < DOUBLE_TAP_MS &&
|
||||
Math.abs(touch.clientX - lastX) < DOUBLE_TAP_SLOP &&
|
||||
Math.abs(touch.clientY - lastY) < DOUBLE_TAP_SLOP;
|
||||
if (!isDouble) {
|
||||
lastTap = now;
|
||||
lastX = touch.clientX;
|
||||
lastY = touch.clientY;
|
||||
return;
|
||||
}
|
||||
lastTap = 0; // consume so a third tap starts fresh
|
||||
e.preventDefault();
|
||||
const rect = root.getBoundingClientRect();
|
||||
zoom.current.step = (zoom.current.step + 1) % ZOOM_STEPS.length;
|
||||
applyZoom(ZOOM_STEPS[zoom.current.step], touch.clientX - rect.left, touch.clientY - rect.top);
|
||||
};
|
||||
|
||||
root.addEventListener("touchstart", onStart, { passive: true });
|
||||
root.addEventListener("touchmove", onMove, { passive: false });
|
||||
root.addEventListener("touchend", onEnd, { passive: false });
|
||||
return () => {
|
||||
root.removeEventListener("touchstart", onStart);
|
||||
root.removeEventListener("touchmove", onMove);
|
||||
root.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="w-full h-full overflow-auto"
|
||||
// Allow panning, but disable the browser's pinch/double-tap page zoom so
|
||||
// our own double-tap zoom drives the PDF instead of the whole modal.
|
||||
style={{ touchAction: "pan-x pan-y" }}
|
||||
>
|
||||
{status === "loading" && (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
// pdf.js couldn't render this document (corrupt/encrypted, worker load
|
||||
// failure, ...). Offer the OS/native viewer instead of a stuck frame.
|
||||
<div className="flex justify-center py-10">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t("open_in_new_tab")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={pagesRef} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
|
||||
}
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = [
|
||||
"from", "to", "cc", "subject", "header", "size", "body",
|
||||
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||
];
|
||||
|
||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
||||
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === "size") return SIZE_COMPARATORS;
|
||||
if (field === "attachment") return ATTACHMENT_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
||||
];
|
||||
@@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition {
|
||||
return { field: "from", comparator: "contains", value: "" };
|
||||
}
|
||||
|
||||
// Multi-value handling: conditions are stored as string | string[]. The UI
|
||||
// presents them as a single comma-separated text input — the user types
|
||||
// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay
|
||||
// strings so existing single-value rules don't change shape.
|
||||
function valueToInputString(v: string | string[]): string {
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
return v;
|
||||
}
|
||||
|
||||
function inputStringToValue(s: string): string | string[] {
|
||||
const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) return parts[0];
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isConditionValueEmpty(v: string | string[]): boolean {
|
||||
if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim());
|
||||
return !v.trim();
|
||||
}
|
||||
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
@@ -78,7 +107,10 @@ export function FilterRuleModal({
|
||||
const pathMap = new Map<string, string>();
|
||||
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
// Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
|
||||
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
|
||||
const segment = node.role === "inbox" ? "INBOX" : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) buildPaths(node.children, fullPath);
|
||||
}
|
||||
@@ -94,9 +126,23 @@ export function FilterRuleModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(
|
||||
(c) => c.value.trim()
|
||||
);
|
||||
// While editing, condition.value is always the raw string typed into the
|
||||
// input (commas not yet split). Convert to array form here on save so a
|
||||
// user typing "a, b, c" actually persists as ["a","b","c"]. This is the
|
||||
// moment we know editing is finished - splitting earlier would eat any
|
||||
// comma the user just typed mid-edit.
|
||||
const validConditions = conditions
|
||||
.filter((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return true;
|
||||
return !isConditionValueEmpty(c.value);
|
||||
})
|
||||
.map((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return c;
|
||||
if (c.field === "size") return c; // numeric, single-value only
|
||||
if (typeof c.value !== "string") return c; // already structured
|
||||
const parsed = inputStringToValue(c.value);
|
||||
return { ...c, value: parsed };
|
||||
});
|
||||
if (validConditions.length === 0) {
|
||||
toast.error(t("validation_empty_conditions"));
|
||||
return;
|
||||
@@ -126,15 +172,27 @@ export function FilterRuleModal({
|
||||
prev.map((c, i) => {
|
||||
if (i !== index) return c;
|
||||
const updated = { ...c, ...updates };
|
||||
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "greater_than";
|
||||
}
|
||||
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "contains";
|
||||
// Reconcile the comparator when the field changes so we never end up
|
||||
// with e.g. (field=attachment, comparator=contains) — invalid for the
|
||||
// Sieve generator. Each field has its own valid comparator set.
|
||||
if (updates.field && updates.field !== c.field) {
|
||||
const allowed = comparatorsFor(updates.field);
|
||||
if (!allowed.includes(c.comparator)) {
|
||||
updated.comparator = allowed[0];
|
||||
}
|
||||
}
|
||||
if (updates.field && updates.field !== "header") {
|
||||
delete updated.headerName;
|
||||
}
|
||||
// has_any takes no value; clear it so we don't leak old text into
|
||||
// the generated Sieve.
|
||||
if (updated.field === "attachment" && updated.comparator === "has_any") {
|
||||
updated.value = "";
|
||||
}
|
||||
// Size is numeric, single value only - collapse any list to scalar.
|
||||
if (updated.field === "size" && Array.isArray(updated.value)) {
|
||||
updated.value = updated.value[0] ?? "";
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
@@ -278,24 +336,58 @@ export function FilterRuleModal({
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
|
||||
(c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
placeholder={
|
||||
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
{/* has_any takes no value; render a stub so the row layout
|
||||
stays consistent but no input is editable. */}
|
||||
{condition.field === "attachment" && condition.comparator === "has_any" ? (
|
||||
<div className="flex-1 min-w-[120px]" />
|
||||
) : (
|
||||
<Input
|
||||
value={valueToInputString(condition.value)}
|
||||
onChange={(e) =>
|
||||
// Store the raw input string while typing. Splitting
|
||||
// commas into an array on every keystroke would eat
|
||||
// the comma the moment it's typed.
|
||||
updateCondition(index, { value: e.target.value })
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// On blur: normalise comma-separated input into an
|
||||
// array (or single string when only one item). Size
|
||||
// stays numeric/single-value; attachment-has_any has
|
||||
// no value at all.
|
||||
if (condition.field === "size") return;
|
||||
if (
|
||||
condition.field === "attachment" &&
|
||||
condition.comparator === "has_any"
|
||||
)
|
||||
return;
|
||||
const parsed = inputStringToValue(e.target.value);
|
||||
// Only update if the normalised shape actually
|
||||
// differs - avoids triggering a no-op re-render and
|
||||
// resetting the user's cursor on every blur.
|
||||
if (
|
||||
JSON.stringify(parsed) !== JSON.stringify(condition.value)
|
||||
) {
|
||||
updateCondition(index, { value: parsed });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
condition.field === "size"
|
||||
? t("size_placeholder")
|
||||
: condition.field === "attachment"
|
||||
? t("attachment_type_placeholder")
|
||||
: t("value_placeholder_multi")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
|
||||
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
@@ -206,6 +207,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
// Persist to the synced settings (keyed by username, matching how
|
||||
// loadIdentities reads it back) so the choice survives a new browser /
|
||||
// cleared site data and reaches other devices (#507).
|
||||
const username = useAuthStore.getState().username || '';
|
||||
if (username) {
|
||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||
...current,
|
||||
[username]: identity.id,
|
||||
});
|
||||
}
|
||||
// Re-sort: move the preferred identity to the front
|
||||
const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)];
|
||||
useIdentityStore.getState().setIdentities(reordered);
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
@@ -166,7 +166,6 @@ export function NavigationRail({
|
||||
collapsed = false,
|
||||
className,
|
||||
quota,
|
||||
isPushConnected,
|
||||
onLogout,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
@@ -191,6 +190,7 @@ export function NavigationRail({
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
@@ -269,7 +269,7 @@ export function NavigationRail({
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar || !calendarEnabled },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
|
||||
];
|
||||
@@ -385,7 +385,7 @@ export function NavigationRail({
|
||||
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -444,7 +444,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
@@ -546,7 +546,7 @@ export function NavigationRail({
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
{sidebarAppsEnabled && onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
className={cn(
|
||||
@@ -572,7 +572,7 @@ export function NavigationRail({
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
|
||||
@@ -77,9 +77,11 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
||||
onKeyDown={handleKeyDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={cn(
|
||||
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
||||
// Centered over the seam with negative margins so the handle adds no
|
||||
// net layout space (panes sit flush) while staying draggable.
|
||||
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group z-10",
|
||||
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
|
||||
isHorizontal ? "h-1 -my-0.5 cursor-row-resize bg-border" : "w-1 -mx-0.5 cursor-col-resize",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
+161
-68
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -33,13 +33,16 @@ import {
|
||||
NotebookPen,
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
Mails,
|
||||
MailOpen,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
||||
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
|
||||
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||
@@ -74,6 +77,16 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
|
||||
showAllMailMailbox?: boolean;
|
||||
/** Gated cross-account views in the "All accounts" section. */
|
||||
showCrossUnread?: boolean;
|
||||
showCrossStarred?: boolean;
|
||||
showCrossAll?: boolean;
|
||||
/** Unread total across all cross-view folders (badge for unread/all). */
|
||||
crossUnreadCount?: number;
|
||||
className?: string;
|
||||
/**
|
||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||
@@ -164,7 +177,6 @@ function getIconClass(isSelected: boolean, isVirtual: boolean, colorful: boolean
|
||||
function SidebarRowCounts({
|
||||
unread,
|
||||
total,
|
||||
isSelected,
|
||||
onUnreadClick,
|
||||
}: {
|
||||
unread?: number;
|
||||
@@ -172,8 +184,9 @@ function SidebarRowCounts({
|
||||
isSelected: boolean;
|
||||
onUnreadClick?: () => void;
|
||||
}) {
|
||||
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
|
||||
const unreadCount = unread ?? 0;
|
||||
const totalCount = total ?? 0;
|
||||
const totalCount = showFolderTotalCount ? (total ?? 0) : 0;
|
||||
|
||||
if (unreadCount === 0 && totalCount === 0) return null;
|
||||
|
||||
@@ -207,7 +220,7 @@ function SidebarRowCounts({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={`${unreadCount} unread / ${totalCount} total`}>
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
|
||||
{unreadNode}
|
||||
{unreadCount > 0 && totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">/</span>
|
||||
@@ -428,12 +441,14 @@ function MailboxTreeItem({
|
||||
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||
}) {
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||
const isVirtualNode = node.id.startsWith('shared-');
|
||||
const isSelected = selectedMailbox === node.id;
|
||||
const roleKey = resolveRoleKey(node.role, node.name);
|
||||
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
||||
@@ -460,7 +475,7 @@ function MailboxTreeItem({
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
||||
label={node.name}
|
||||
label={label}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
isVirtual={isVirtualNode}
|
||||
@@ -674,6 +689,13 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
showAllMailMailbox = false,
|
||||
showCrossUnread = false,
|
||||
showCrossStarred = false,
|
||||
showCrossAll = false,
|
||||
crossUnreadCount = 0,
|
||||
className,
|
||||
multiAccountMode = false,
|
||||
accountMailboxes,
|
||||
@@ -732,15 +754,20 @@ export function Sidebar({
|
||||
// pane.
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
||||
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
||||
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
|
||||
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||
// 2+ account requirement still applies - with a single account the
|
||||
// unified counts would just duplicate that account's inbox.
|
||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
|
||||
// single account we still surface unified when the user has opted into
|
||||
// merging group/shared inboxes — otherwise the counts would just duplicate
|
||||
// the one inbox.
|
||||
const showUnified =
|
||||
(multiAccountMode || enableUnifiedMailbox) &&
|
||||
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
@@ -784,8 +811,14 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
// so it does not appear twice. (#495)
|
||||
const isServerScheduledNode = (n: MailboxNode) => showScheduledMailbox && n.role === 'scheduled';
|
||||
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
@@ -800,7 +833,7 @@ export function Sidebar({
|
||||
? mailboxes
|
||||
: (accountMailboxes?.[account.id] ?? []);
|
||||
const tree = buildMailboxTree(accountMailboxList).filter(
|
||||
(n) => !n.id.startsWith('shared-account-')
|
||||
(n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n))
|
||||
);
|
||||
return { account, isActive, tree };
|
||||
})
|
||||
@@ -895,11 +928,11 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
const openFolderSettings = () => {
|
||||
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
||||
try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ }
|
||||
router.push('/settings');
|
||||
};
|
||||
const openKeywordSettings = () => {
|
||||
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
||||
try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ }
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
@@ -933,30 +966,34 @@ export function Sidebar({
|
||||
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
||||
would otherwise render an empty strip (no collapse, no switcher). */}
|
||||
{!isEmbedded && (
|
||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSidebarClose}
|
||||
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
// Border lives on the wrapper (outside the h-14 box) so the bar's total
|
||||
// height matches the search/reply toolbars, which border-b their wrapper too.
|
||||
<div className="border-b border-border">
|
||||
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSidebarClose}
|
||||
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||
>
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||
>
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{!isCollapsed && !hideAccountSwitcher && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
{!isCollapsed && !hideAccountSwitcher && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -965,7 +1002,17 @@ export function Sidebar({
|
||||
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||
{showUnified && (
|
||||
{showAllMailMailbox && (
|
||||
<SidebarRow
|
||||
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('mailboxes.all_mail')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
|
||||
onClick={() => onMailboxSelect?.('__all_mail__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("all_accounts")}
|
||||
@@ -976,7 +1023,7 @@ export function Sidebar({
|
||||
/>
|
||||
{((unifiedExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{unifiedCounts.map((count) => {
|
||||
{showUnified && unifiedCounts.map((count) => {
|
||||
const unifiedId = UNIFIED_MAILBOX_IDS[count.role];
|
||||
const Icon = getUnifiedIcon(count.role);
|
||||
const isSelected = !selectedKeyword && selectedMailbox === unifiedId;
|
||||
@@ -994,6 +1041,26 @@ export function Sidebar({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{[
|
||||
{ show: showCrossUnread, id: CROSS_VIEW_IDS.unread, Icon: MailOpen, label: t('unified_all_unread'), unread: crossUnreadCount },
|
||||
{ show: showCrossStarred, id: CROSS_VIEW_IDS.starred, Icon: Star, label: t('unified_all_starred'), unread: undefined as number | undefined },
|
||||
{ show: showCrossAll, id: CROSS_VIEW_IDS.all, Icon: Mails, label: t('unified_all_mail'), unread: crossUnreadCount },
|
||||
].map(({ show, id, Icon, label, unread }) => {
|
||||
if (!show) return null;
|
||||
const isSelected = !selectedKeyword && selectedMailbox === id;
|
||||
return (
|
||||
<SidebarRow
|
||||
key={id}
|
||||
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
|
||||
label={label}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unread}
|
||||
onClick={() => onMailboxSelect?.(id)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1022,22 +1089,35 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
))}
|
||||
{isActive && showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1062,20 +1142,33 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))}
|
||||
{showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
// Lightweight **bold** support in plugin dialog messages. Everything else is
|
||||
// rendered literally (newlines come from the parent's white-space: pre-wrap).
|
||||
// Splitting on the ** delimiter yields alternating plain/bold segments (odd
|
||||
// indices are bold). Plugins control these strings, so the delimiters balance.
|
||||
function renderMessage(message?: string): React.ReactNode {
|
||||
if (!message) return null;
|
||||
return message.split('**').map((seg, i) =>
|
||||
i % 2 === 1
|
||||
? <strong key={i}>{seg}</strong>
|
||||
: <React.Fragment key={i}>{seg}</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'var(--color-popover, #fff)',
|
||||
color: 'var(--color-popover-foreground, #0f172a)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
||||
{current.message}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{renderMessage(current.message)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
||||
color: '#fff',
|
||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
|
||||
import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
@@ -50,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
slot,
|
||||
code: active.code,
|
||||
locale,
|
||||
tier: active.tier,
|
||||
extraProps: extraProps ?? {},
|
||||
hostContainer: wrapperRef.current,
|
||||
onResize: (h) => setHeight(h),
|
||||
@@ -69,6 +72,16 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
instanceRef.current?.updateProps(extraProps ?? {});
|
||||
}, [extraProps]);
|
||||
|
||||
// Re-theme the live slot iframe when the host theme changes (dark/light
|
||||
// toggle or custom theme switch), without tearing down the iframe. Reacting
|
||||
// to resolvedTheme + activeThemeId covers both; the snapshot reads the
|
||||
// resolved DOM values so it picks up whichever is active.
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
useEffect(() => {
|
||||
instanceRef.current?.setTheme(snapshotHostTheme());
|
||||
}, [resolvedTheme, activeThemeId]);
|
||||
|
||||
if (show !== true) return null;
|
||||
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
|
||||
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
|
||||
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
|
||||
const isScheduledView = useEmailStore((s) => s.isScheduledView);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||
@@ -36,10 +38,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const tabIdRef = useRef(tabId);
|
||||
tabIdRef.current = tabId;
|
||||
|
||||
const handleScheduledSendCreated = useCallback(async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
}
|
||||
closeTab(tabIdRef.current);
|
||||
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
|
||||
|
||||
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
sendData.to,
|
||||
sendData.subject,
|
||||
@@ -54,9 +64,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
sendData.attachments,
|
||||
sendData.inReplyTo,
|
||||
sendData.references,
|
||||
sendData.delayedUntil,
|
||||
sendData.envelopeMailFrom,
|
||||
);
|
||||
|
||||
if (result.scheduled) {
|
||||
await handleScheduledSendCreated();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original message as $answered / $forwarded so the standard
|
||||
// viewer and list reflect the action (same behaviour as inline compose).
|
||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
||||
@@ -75,13 +91,30 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
|
||||
// Refresh the currently-active mail list so the new sent message /
|
||||
// updated keyword status shows up.
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
await refreshCurrentMailbox(client);
|
||||
// Re-fetch the replied thread's cross-folder data so the expanded
|
||||
// view shows the newly sent reply without collapsing.
|
||||
if (data.sourceEmailId) {
|
||||
const emailState = useEmailStore.getState();
|
||||
const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
|
||||
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||
const accountId = client.getAccountId();
|
||||
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
if (fullEmails.length > 0) {
|
||||
useEmailStore.setState((state) => {
|
||||
const c = new Map(state.threadEmailsCache);
|
||||
c.set(repliedEmail.threadId!, fullEmails);
|
||||
return { threadEmailsCache: c };
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
closeTab(tabIdRef.current);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(t('notifications.error_sending'));
|
||||
}
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
|
||||
}, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
@@ -125,6 +158,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
onSend={handleSend}
|
||||
onScheduledSendCreated={handleScheduledSendCreated}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user