Compare commits

..
27 Commits
Author SHA1 Message Date
Linus Rath d0c7e991ff Merge branch 'main' into feature/scheduled-send 2026-05-28 18:43:13 +02:00
Lucas Gaitzsch d1b2206aa0 fixes 2026-05-26 18:43:01 +02:00
Lucas Gaitzsch 0137a1a593 Merge branch 'main' into feature/scheduled-send
# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/email/email-list.tsx
#	components/email/email-viewer.tsx
2026-05-26 18:30:45 +02:00
Lucas GaitzschandGitHub c4aa0c1772 Merge branch 'bulwarkmail:main' into feature/scheduled-send 2026-05-23 06:39:35 +02:00
Lucas Gaitzsch 9a302e5183 fix styling 2026-05-22 18:14:57 +02:00
Lucas Gaitzsch 401870c9bb add translations 2026-05-22 18:05:54 +02:00
Lucas Gaitzsch 91cd087243 fix email store lazy load 2026-05-22 18:01:48 +02:00
Lucas Gaitzsch d04a8e578b disable password managers for recipients 2026-05-22 18:01:31 +02:00
Lucas Gaitzsch e6d939ba43 fixes from review 2026-05-22 14:46:57 +02:00
Lucas Gaitzsch 5c1d59f38a fixes from review 2026-05-22 13:13:13 +02:00
Lucas Gaitzsch 159706a683 Merge branch 'main' into feature/scheduled-send
# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
2026-05-22 12:31:06 +02:00
Lucas Gaitzsch 5928c6f73e Merge branch 'main' into feature/scheduled-send 2026-05-22 12:21:39 +02:00
Lucas Gaitzsch 7dccc2f432 fixes from review 2026-05-22 12:21:32 +02:00
Lucas Gaitzsch dd322d4c9d some fixes 2026-05-20 20:04:46 +02:00
Lucas Gaitzsch 7e15782fb3 fix err 2026-05-20 20:00:17 +02:00
Lucas Gaitzsch 1e8bd40b93 fix draft duplicating 2026-05-20 19:58:41 +02:00
Lucas Gaitzsch 3dc1b4ceee rework 2026-05-20 19:56:28 +02:00
Lucas Gaitzsch 1a3d359fee fix bugs 2026-05-20 08:28:31 +02:00
Lucas Gaitzsch 891c3250be Merge branch 'main' into feature/scheduled-send 2026-05-20 08:17:46 +02:00
Lucas Gaitzsch 4a3c775b40 fix 2026-05-09 00:31:34 +02:00
Lucas Gaitzsch f45b67fe19 fix 2026-05-08 22:37:16 +02:00
Lucas Gaitzsch c458091698 add new shortcuts 2026-05-08 20:41:19 +02:00
Lucas Gaitzsch 37cd5ca635 Merge remote-tracking branch 'origin/main' into feature/scheduled-send
# Conflicts:
#	app/[locale]/page.tsx
#	components/email/email-composer.tsx
#	components/email/email-viewer.tsx
2026-05-07 18:01:41 +02:00
Lucas Gaitzsch 154ae84247 Scheduld Send 2026-05-05 20:11:11 +02:00
Lucas Gaitzsch 16f719066f Merge branch 'main' into feature/scheduled-send 2026-05-04 15:46:50 +02:00
Lucas Gaitzsch 17577e222c Merge branch 'main' into feature/scheduled-send 2026-05-04 15:45:15 +02:00
Lucas Gaitzsch 48821338c3 ADD DOC 2026-05-03 22:44:16 +02:00
185 changed files with 1827 additions and 18178 deletions
-5
View File
@@ -47,8 +47,3 @@ 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"}]
+4 -31
View File
@@ -110,16 +110,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Anonymous Telemetry
# =============================================================================
# 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
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# 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.
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
@@ -229,29 +225,6 @@ 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
# =============================================================================
-129
View File
@@ -1,134 +1,5 @@
# Changelog
## 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
+9 -16
View File
@@ -7,10 +7,7 @@
- Unified mailbox view across all connected accounts
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview 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
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month
@@ -39,7 +36,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, multi-account home resolution, and per-viewer colors
- Shared calendars with CalDAV discovery and multi-account home resolution
- Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push
@@ -55,7 +52,7 @@
## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
- Preserves rules authored in other clients
- Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling
@@ -63,20 +60,19 @@
## Files
- 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
- JMAP FileNode browser (Stalwart native cloud storage)
- 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 surfaces the most severe SPF result and hides the "via" badge on spoofed mail
- SPF / DKIM / DMARC status indicators
- 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
@@ -89,7 +85,6 @@
- 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
@@ -103,7 +98,7 @@
## Internationalization
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
@@ -123,9 +118,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- 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 system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
@@ -133,10 +126,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, dynamic manifest, and configurable (per-domain) install screenshots
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) version, platform, bucketed account counts, feature toggles only
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- 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
+2 -8
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
@@ -86,7 +86,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 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, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**.
@@ -211,12 +211,6 @@ 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>
+1 -1
View File
@@ -1 +1 @@
1.7.4
1.7.1
+4 -40
View File
@@ -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 { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -32,42 +32,6 @@ 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) {
@@ -105,7 +69,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(toRouterPath(redirectTo));
router.push(redirectTo);
} else {
setError("token_exchange_failed");
}
@@ -189,7 +153,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(toRouterPath(redirectTo));
router.push(redirectTo);
} else {
setError("token_exchange_failed");
}
@@ -217,7 +181,7 @@ function OAuthCallbackInner() {
</p>
<Button
variant="outline"
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
>
{t("oauth_error.back_to_login")}
</Button>
+10 -64
View File
@@ -60,7 +60,6 @@ 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";
@@ -98,9 +97,6 @@ export default function CalendarPage() {
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore();
@@ -1034,47 +1030,10 @@ 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 displayCalendars;
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
if (!showBirthdayCalendar) return calendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
@@ -1260,7 +1219,7 @@ export default function CalendarPage() {
/>
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
@@ -1358,21 +1317,8 @@ 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);
@@ -1443,7 +1389,7 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={displayCalendars}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
@@ -1473,7 +1419,7 @@ export default function CalendarPage() {
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={displayCalendars}
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
@@ -1496,7 +1442,7 @@ export default function CalendarPage() {
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={displayCalendars}
calendars={calendars}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
@@ -1583,7 +1529,7 @@ export default function CalendarPage() {
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
anchorRect={detailAnchorRect}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -1603,7 +1549,7 @@ export default function CalendarPage() {
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={displayCalendars}
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
@@ -1620,7 +1566,7 @@ export default function CalendarPage() {
{showImportModal && client && (
<ICalImportModal
calendars={displayCalendars}
calendars={calendars}
client={client}
initialUrl={pendingSubscription?.url}
onClose={() => {
+1 -2
View File
@@ -173,13 +173,12 @@ 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(viewParam === 'edit' ? 'edit' : 'detail');
setView('detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
+3 -48
View File
@@ -22,13 +22,12 @@ 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, Loader2 } from "lucide-react";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
const router = useRouter();
@@ -49,11 +48,9 @@ export default function FilesPage() {
supportsFiles,
selectedResources,
uploadProgress,
migrationProgress,
clipboard,
initClient,
checkSupport,
migrateLegacyFlatNodes,
navigate,
navigateByPath,
refresh,
@@ -89,7 +86,6 @@ export default function FilesPage() {
cancelUpload,
undoLastAction,
lastAction,
shareResource,
} = useFileStore();
const isMobile = useIsMobile();
@@ -164,16 +160,13 @@ export default function FilesPage() {
const storeClient = useFileStore(s => s.client);
useEffect(() => {
if (storeClient && supportsFiles === null) {
checkSupport().then(async (supported) => {
checkSupport().then((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, migrateLegacyFlatNodes, navigate]);
}, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
@@ -403,14 +396,6 @@ 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.
@@ -550,10 +535,6 @@ export default function FilesPage() {
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
client={storeClient}
ownAccountId={filesAccountId}
sharingEnabled={sharingEnabled}
onShare={handleShare}
/>
</div>
)}
@@ -592,32 +573,6 @@ 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>
-2
View File
@@ -9,7 +9,6 @@ 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({
@@ -42,7 +41,6 @@ export default async function LocaleLayout({
{children}
<PluginDialogHost />
<PluginConsentDialog />
<PWAInstallPrompt />
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
+7 -18
View File
@@ -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, toRouterPath, withBasePath } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { discoverOAuth, 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(toRouterPath(redirectTo));
router.push(redirectTo);
}
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
@@ -329,27 +329,16 @@ export default function LoginPage() {
if (!oauthEnabled || !serverUrl) return;
setOauthDiscoveryDone(false);
setOauthMetadata(null);
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))
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
.then((metadata) => {
setOauthMetadata(metadata);
setOauthDiscoveryDone(true);
})
.catch((err) => {
if (err?.name === "AbortError") return;
.catch(() => {
setOauthMetadata(null);
setOauthDiscoveryDone(true);
});
return () => controller.abort();
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error");
@@ -655,7 +644,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(toRouterPath(redirectTo));
router.push(redirectTo);
}
};
+29 -144
View File
@@ -11,9 +11,8 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
@@ -56,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { resolveReplyFrom } from "@/lib/reply-identity";
@@ -73,7 +72,6 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types";
@@ -83,7 +81,6 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__';
export default function Home() {
const t = useTranslations();
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false);
@@ -302,7 +299,6 @@ export default function Home() {
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
unifiedRole,
scheduledEmails,
scheduledTotal,
scheduledHasMore,
@@ -335,7 +331,6 @@ export default function Home() {
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
refreshCurrentMailbox,
} = useEmailStore();
// Pro shell: populate per-account mailbox cache so the sidebar can render
@@ -343,10 +338,7 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
@@ -616,7 +608,6 @@ export default function Home() {
}
},
onCompose: () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -759,9 +750,9 @@ export default function Home() {
let title = t('email_composer.new_message');
if (baseSubject) {
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
} else if (effectiveMode === 'forward') {
title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
} else {
title = baseSubject;
}
@@ -1137,7 +1128,6 @@ export default function Home() {
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
requestReadReceipt?: boolean;
}) => {
if (!client) return;
@@ -1145,7 +1135,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id;
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
setShowComposer(false);
if (result.scheduled) {
await refreshScheduledMetadata(client);
@@ -1169,26 +1159,7 @@ export default function Home() {
}
// Refresh the current mailbox to update the UI
if (!isScheduledView) {
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (originalEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
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 };
});
}
}
}
}
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -1240,29 +1211,13 @@ export default function Home() {
locale: useLocaleStore.getState().locale,
timeFormat: useSettingsStore.getState().timeFormat,
unknownLabel: tCommon('unknown'),
labels: {
formatReplyLine: (vars) => tQuote('reply_line', vars),
forwardedSeparator: tQuote('forwarded_separator'),
fromLabel: tQuote('from_label'),
dateLabel: tQuote('date_label'),
subjectLabel: tQuote('subject_label'),
},
});
setComposerQuoteHeader(header);
} catch (err) {
console.warn('[quote-header] plugin transform failed; using default', err);
setComposerQuoteHeader(null);
}
}, [tCommon, tQuote]);
// Force a clean composer remount on every fresh entry point so prior
// compose state can't bleed into the new session (#329 C). The composer is
// keyed on composerSessionId, and pendingDraft would otherwise pin the
// composer to a stale draft from a discarded reply.
const startFreshComposerSession = useCallback(() => {
setComposerSessionId(id => id + 1);
setPendingDraft(null);
}, []);
}, [tCommon]);
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
@@ -1276,7 +1231,6 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -1386,7 +1340,6 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1404,7 +1357,6 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1413,19 +1365,8 @@ export default function Home() {
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return;
// In unified view the trash destination and current-folder check must come
// from the email's own account, not the active one. (#281)
const actionMailboxes =
isUnifiedView && emailToDelete.accountId
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes)
: mailboxes;
// Check if we're currently in the trash or junk folder. In unified view the
// "current folder" is the unified role within the email's account.
const currentMailbox = isUnifiedView
? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id])
?? actionMailboxes.find(m => m.role === unifiedRole))
: mailboxes.find(m => m.id === selectedMailbox);
// Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
@@ -1446,10 +1387,10 @@ export default function Home() {
console.error("Failed to permanently delete email:", error);
}
} else {
// Not in trash: always move to trash (in the email's own account).
// Not in trash: always move to trash
const trashMailbox =
actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ??
actionMailboxes.find(m => {
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
mailboxes.find(m => {
if (m.isShared) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
@@ -1472,27 +1413,9 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return;
// In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through that
// account's client. (#281)
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined;
const archiveClient = archiveAccountId
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client)
: client;
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
const readMailboxes = () => {
const s = useEmailStore.getState();
return archiveAccountId
? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes)
: s.mailboxes;
};
const refreshMailboxes = () =>
archiveAccountId
? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId)
: fetchMailboxes(client);
const currentMailboxes = readMailboxes();
const currentMailboxes = useEmailStore.getState().mailboxes;
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return;
@@ -1512,21 +1435,21 @@ export default function Home() {
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
yearMailbox = await archiveClient.createMailbox(year, archiveId);
await refreshMailboxes();
yearMailbox = await client.createMailbox(year, archiveId);
await fetchMailboxes(client);
}
if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else {
const yearId = yearMailbox.originalId || yearMailbox.id;
const afterYear = readMailboxes();
const afterYear = useEmailStore.getState().mailboxes;
let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
monthMailbox = await archiveClient.createMailbox(month, yearId);
await refreshMailboxes();
monthMailbox = await client.createMailbox(month, yearId);
await fetchMailboxes(client);
}
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
}
@@ -1537,7 +1460,7 @@ export default function Home() {
setConversationEmails([]);
}
void refreshMailboxes();
void fetchMailboxes(client);
} catch (error) {
console.error("Failed to archive email:", error);
}
@@ -2154,21 +2077,9 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias).
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
// When the identity has an HTML signature, send a matching HTML body so the
// signature keeps its formatting; appendPlainTextSignature would otherwise
// flatten it to plain text. Text-only identities keep the plain-text-only
// behavior (htmlBody stays undefined).
const escapedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
: undefined;
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
separator: useSettingsStore.getState().signatureSeparatorEnabled,
});
const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
@@ -2192,7 +2103,7 @@ export default function Home() {
const result = await sendEmail(
client,
[sender.email],
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
`Re: ${selectedEmail.subject || "(no subject)"}`,
finalBody,
undefined,
undefined,
@@ -2200,7 +2111,7 @@ export default function Home() {
headerFromEmail,
undefined,
headerFromName,
finalHtmlBody,
undefined,
undefined,
threading?.inReplyTo,
threading?.references,
@@ -2221,22 +2132,7 @@ export default function Home() {
}
// Refresh emails to show the sent reply
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
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 };
});
}
}
await fetchEmails(client, selectedMailbox);
};
// Show loading state while checking auth
@@ -2252,11 +2148,7 @@ export default function Home() {
}
// Get current mailbox name for mobile header
const currentMailboxName = isScheduledView
? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail')
: mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -2386,7 +2278,6 @@ export default function Home() {
const handleConversationReply = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
startFreshComposerSession();
setComposerMode('reply');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2395,7 +2286,6 @@ export default function Home() {
const handleConversationReplyAll = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2404,7 +2294,6 @@ export default function Home() {
const handleConversationForward = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2509,7 +2398,7 @@ export default function Home() {
),
inlineApp && "hidden"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
@@ -2518,7 +2407,6 @@ export default function Home() {
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
@@ -2533,7 +2421,6 @@ export default function Home() {
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) {
@@ -2594,8 +2481,8 @@ export default function Home() {
{/* Search Bar + Inline Advanced Filters */}
<div className="border-b border-border bg-background">
<div className="px-3 h-14 flex items-center">
<div className="flex items-center gap-1.5 w-full">
<div className="px-3 py-3">
<div className="flex items-center gap-1.5">
{/* Select / Select All toggle */}
<button
type="button"
@@ -2918,7 +2805,6 @@ export default function Home() {
{/* Floating Compose Button */}
<Button
onClick={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -3137,7 +3023,6 @@ export default function Home() {
}
}}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
}}
+11 -81
View File
@@ -33,7 +33,6 @@ import {
Languages,
Info,
Bug,
SwatchBook,
Download,
X,
type LucideIcon,
@@ -74,7 +73,6 @@ 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';
@@ -147,7 +145,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: SwatchBook,
themes: Palette,
plugins: Puzzle,
debug: Bug,
};
@@ -183,7 +181,6 @@ 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',
],
@@ -200,11 +197,11 @@ 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',
@@ -338,14 +335,6 @@ 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) {
@@ -371,11 +360,6 @@ 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);
@@ -387,12 +371,6 @@ 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
@@ -496,10 +474,6 @@ 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 */ }
@@ -608,7 +582,6 @@ 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' },
@@ -634,32 +607,17 @@ export default function SettingsPage() {
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// 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 ? '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: visibleTabs.filter((tab) => tab.group === group),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
@@ -687,13 +645,9 @@ export default function SettingsPage() {
.filter((g) => g.items.length > 0)
: groupedTabs;
// 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');
// 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';
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
@@ -708,26 +662,10 @@ export default function SettingsPage() {
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const activeTabLabel = tabs.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 />}
@@ -745,16 +683,8 @@ export default function SettingsPage() {
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
)}
{effectiveActiveTab === 'contacts' && (
managedAccountId
? <AddressBookManagementSettings />
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
)}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
@@ -1003,7 +933,7 @@ export default function SettingsPage() {
return (
<div key={tab.id}>
<button
onClick={() => handleTabSelect(tab.id)}
onClick={() => setActiveTab(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
+44 -317
View File
@@ -1,14 +1,8 @@
'use client';
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';
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value?: unknown;
@@ -22,67 +16,42 @@ 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, string>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
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');
@@ -90,81 +59,29 @@ 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];
if (selectedHost) {
const entry = selectedEntry();
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
}
if (key in edits) return edits[key] as string;
return (config[key]?.value as string) ?? '';
}
function isOverriddenInScope(key: string): boolean {
if (selectedHost) {
const entry = selectedEntry();
const v = entry?.[key as BrandingOverrideKey];
return typeof v === 'string' && v.length > 0;
}
return config[key]?.source === 'admin';
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
if (!selectedHost) return domainEntries;
const next = domainEntries.slice();
const idx = next.findIndex(e => e.host === selectedHost);
const base: DomainBrandingEntry =
idx === -1 ? { host: selectedHost } : { ...next[idx] };
const writable = base as unknown as Record<string, string | undefined>;
for (const [key, value] of Object.entries(merge)) {
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
if (typeof value === 'string' && value.length > 0) {
writable[key] = value;
} else {
delete writable[key];
}
}
if (idx === -1) next.push(base);
else next[idx] = base;
return next;
}
async function handleSave() {
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(payload),
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({
type: 'success',
text: selectedHost
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
: 'Branding updated. Changes visible on next page load.',
});
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
setEdits({});
await fetchConfig();
} else {
@@ -175,20 +92,12 @@ 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',
@@ -203,9 +112,10 @@ export function BrandingTab() {
delete next[slot];
return next;
});
// Refresh from server so domainBranding entries reflect the upload.
await fetchConfig();
void data;
setConfig(prev => ({
...prev,
[slot]: { value: data.url, source: 'admin' },
}));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
@@ -216,13 +126,10 @@ 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(body),
body: JSON.stringify({ slot }),
});
if (res.ok) {
@@ -240,25 +147,6 @@ 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' },
@@ -274,71 +162,12 @@ export function BrandingTab() {
}
}
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 isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
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>;
@@ -363,102 +192,6 @@ 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}
@@ -476,9 +209,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>
{isOverriddenInScope(field.key) && (
{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">
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
@@ -487,7 +220,7 @@ export function BrandingTab() {
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'}
placeholder="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
@@ -503,9 +236,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key || wildcardScope}
disabled={uploading === field.key}
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'}
title="Upload file"
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -518,7 +251,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
{config[field.key]?.source === 'admin' && !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>
@@ -530,7 +263,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={withBasePath(currentValue(field.key))}
src={currentValue(field.key)}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -554,9 +287,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>
{isOverriddenInScope(field.key) && (
{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">
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
@@ -565,7 +298,7 @@ export function BrandingTab() {
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'}
placeholder="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
@@ -581,9 +314,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key || wildcardScope}
disabled={uploading === field.key}
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'}
title="Upload file"
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -596,7 +329,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
{config[field.key]?.source === 'admin' && !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>
@@ -608,7 +341,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={withBasePath(currentValue(field.key))}
src={currentValue(field.key)}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -622,10 +355,8 @@ 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>
{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>
{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>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -636,7 +367,7 @@ export function BrandingTab() {
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) && (
{config[field.key]?.source === 'admin' && (
<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>
@@ -650,10 +381,8 @@ 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>
{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>
{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>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -671,7 +400,7 @@ export function BrandingTab() {
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) && (
{config[field.key]?.source === 'admin' && (
<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>
@@ -692,10 +421,8 @@ 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>
{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>
{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>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -706,7 +433,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"
/>
{isOverriddenInScope(field.key) && (
{config[field.key]?.source === 'admin' && (
<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>
+20
View File
@@ -32,6 +32,7 @@ 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();
@@ -81,6 +82,15 @@ 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();
@@ -118,6 +128,16 @@ 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'}
-41
View File
@@ -21,7 +21,6 @@ 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 accounts folders into one list. Users choose which folders are included.' },
};
const RESTRICTABLE_SETTINGS = [
@@ -75,18 +74,6 @@ 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] || {};
@@ -196,34 +183,6 @@ 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">
-1
View File
@@ -125,7 +125,6 @@ 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)">
+5 -6
View File
@@ -118,10 +118,9 @@ 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 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&apos;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.{' '}
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.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
@@ -139,8 +138,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. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
+5 -27
View File
@@ -1,25 +1,13 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
import { 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"],
@@ -39,21 +27,10 @@ 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: 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 },
description: "Minimalist webmail client using JMAP protocol",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
@@ -71,7 +48,7 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const locale = await resolveRequestLocale();
const locale = await getLocale();
const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
@@ -115,6 +92,7 @@ export default async function RootLayout({
>
<ServiceWorkerRegistration />
{children}
<PWAInstallPrompt />
</body>
</html>
);
+1 -16
View File
@@ -38,7 +38,6 @@ interface WizardConfig {
// Security
sessionSecret: string;
settingsSyncEnabled: boolean;
telemetryEnabled: boolean;
// Logging
logFormat: 'text' | 'json';
logLevel: 'error' | 'warn' | 'info' | 'debug';
@@ -67,7 +66,6 @@ const EMPTY_CONFIG: WizardConfig = {
oauthIssuerUrl: '',
sessionSecret: '',
settingsSyncEnabled: true,
telemetryEnabled: false,
logFormat: 'text',
logLevel: 'info',
faviconUrl: '',
@@ -1004,11 +1002,8 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
e.preventDefault();
setSubmitting(true);
try {
// 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> = {
const values: Partial<WizardConfig> = {
settingsSyncEnabled: config.settingsSyncEnabled,
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
};
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
await onNext('security', values);
@@ -1073,15 +1068,6 @@ 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}>
@@ -1494,7 +1480,6 @@ 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">
+15 -9
View File
@@ -1,14 +1,17 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import '../globals.css';
// 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.
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Plugin sandbox',
@@ -18,7 +21,10 @@ export const metadata: Metadata = {
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
{children}
</body>
</html>
+32 -159
View File
@@ -3,13 +3,8 @@ 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, readdir } from 'node:fs/promises';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
@@ -26,99 +21,27 @@ 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<UploadSlot>([
const VALID_SLOTS = new Set([
'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 {
@@ -129,24 +52,15 @@ 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 as UploadSlot)) {
if (!VALID_SLOTS.has(slot)) {
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 });
}
@@ -158,51 +72,34 @@ export async function POST(request: NextRequest) {
);
}
const ext = EXT_BY_MIME[file.type] ?? '.png';
const safeName = host
? domainAssetName(host, slot as BrandingOverrideKey, ext)
: sanitizeFilename(`${slot}${ext}`);
// 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 filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists
if (!existsSync(getBrandingDir())) {
await mkdir(getBrandingDir(), { recursive: true });
}
// 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 */ }
}
}
// Write file to disk
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 });
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);
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
@@ -214,11 +111,7 @@ export async function POST(request: NextRequest) {
/**
* DELETE /api/admin/branding - Remove an uploaded branding file
*
* 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.
* Expects JSON body: { slot: string }
*/
export async function DELETE(request: NextRequest) {
try {
@@ -226,48 +119,28 @@ export async function DELETE(request: NextRequest) {
if ('error' in result) return result.error;
const ip = getClientIP(request);
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
const slot = body.slot;
const rawHost = body.host ?? '';
const { slot } = await request.json();
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
if (!slot || !VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
}
const dir = getBrandingDir();
// Find and remove matching files for this slot
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false;
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;
}
for (const ext of possibleExts) {
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
}
}
// Clear the config override so it falls back to default/env
await configManager.ensureLoaded();
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 configManager.removeAdminOverride(slot);
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
return NextResponse.json({ success: true });
} catch (error) {
-20
View File
@@ -4,7 +4,6 @@ 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
@@ -89,25 +88,6 @@ 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)) {
-3
View File
@@ -192,9 +192,6 @@ 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 }
: {}),
-53
View File
@@ -1,53 +0,0 @@
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 });
}
}
-96
View File
@@ -1,96 +0,0 @@
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 });
}
}
-36
View File
@@ -1,36 +0,0 @@
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 });
}
}
-70
View File
@@ -1,70 +0,0 @@
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 });
}
}
-15
View File
@@ -30,14 +30,8 @@ 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 });
}
@@ -91,7 +85,6 @@ 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);
@@ -116,14 +109,6 @@ 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,
-334
View File
@@ -1,334 +0,0 @@
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;
}
+36 -66
View File
@@ -1,15 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { 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
@@ -19,73 +13,49 @@ import {
* post-build configuration for Docker deployments.
*
* Priority order:
* 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
* 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
*/
export async function GET(request: NextRequest) {
export async function GET() {
logger.debug('Config requested');
await configManager.ensureLoaded();
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 appName = configManager.get<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: 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' },
},
);
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', ''),
});
}
+7 -60
View File
@@ -121,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: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
cc: [{ name: 'Karel de Vries', 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,
@@ -723,18 +723,7 @@ const emails: MockEmail[] = [
// 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[] = [
const IDENTITIES = [
{
id: 'identity-001',
name: 'Dev User',
@@ -1573,55 +1562,13 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string }> = {};
const updated: Record<string, null> = {};
const destroyed: string[] = [];
const create = args.create as Record<string, Record<string, unknown>> | undefined;
const create = args.create as Record<string, unknown> | undefined;
if (create) {
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 };
for (const key of Object.keys(create)) {
created[key] = { id: `identity-new-${Date.now()}-${key}` };
}
}
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];
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
}
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
+12 -17
View File
@@ -19,20 +19,6 @@ 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;
@@ -448,7 +434,10 @@ 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(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
});
}
// Check cache
@@ -471,7 +460,10 @@ export async function GET(request: NextRequest) {
if (!upstream.ok) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
}
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
@@ -481,7 +473,10 @@ export async function GET(request: NextRequest) {
if (data.byteLength < 10) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
}
// Cache the result
-3
View File
@@ -57,9 +57,6 @@ 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
+1 -14
View File
@@ -4,11 +4,6 @@ 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]);
@@ -38,7 +33,7 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
}
export async function GET(
req: NextRequest,
_req: NextRequest,
{ params }: { params: Promise<{ size: string }> }
) {
const { size: sizeParam } = await params;
@@ -49,15 +44,8 @@ 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) {
@@ -67,7 +55,6 @@ 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}`;
-104
View File
@@ -1,104 +0,0 @@
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 });
}
}
-3
View File
@@ -23,13 +23,10 @@ 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> = {
+1 -26
View File
@@ -4,7 +4,6 @@ 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';
@@ -77,32 +76,8 @@ 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(valuesObj)) {
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
-298
View File
@@ -1,298 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
// provider } here via api.http.post; we forward to a free translation backend
// and return { translatedText, detectedSource } in a stable shape.
//
// Two providers:
// - "mymemory" — public MyMemory API, no configuration required. Its
// langpair needs an explicit source language, so when the
// plugin asks for "auto" we detect it locally first.
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
// source auto-detection.
export const runtime = 'nodejs';
const MAX_CHARS = 5000;
// Long bodies are split into ~480-char chunks for MyMemory and translated
// sequentially, so allow enough headroom for ~10 round-trips.
const TIMEOUT_MS = 25000;
type Provider = 'mymemory' | 'libretranslate';
interface TranslateBody {
text?: unknown;
target?: unknown;
source?: unknown;
provider?: unknown;
}
interface TranslateResult {
translatedText: string;
detectedSource?: string;
}
// ─── Lightweight language detection ───────────────────────────
//
// MyMemory has no auto-detect, so we infer a source language from the text.
// Non-Latin scripts are decided by Unicode range; Latin-script European
// languages are scored by stop-word frequency. Detection only needs to be good
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
// already in the target language.
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
[/[぀-ヿ]/, 'ja'], // Hiragana / Katakana
[/[가-힯]/, 'ko'], // Hangul
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
[/[Ͱ-Ͽ]/, 'el'], // Greek
[/[؀-ۿ]/, 'ar'], // Arabic
[/[֐-׿]/, 'he'], // Hebrew
[/[ऀ-ॿ]/, 'hi'], // Devanagari
];
// Distinctive stop words per Latin-script language from the manifest's option
// list. Kept small and high-signal to avoid cross-language collisions.
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
};
function detectLanguage(text: string): string {
const sample = text.slice(0, 1000);
for (const [range, lang] of SCRIPT_RANGES) {
if (range.test(sample)) {
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
return lang;
}
}
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
if (words.length === 0) return 'en';
const counts: Record<string, number> = {};
const wordSet = new Set(words);
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
let score = 0;
for (const stop of stops) if (wordSet.has(stop)) score += 1;
counts[lang] = score;
}
let best = 'en';
let bestScore = -1;
for (const [lang, score] of Object.entries(counts)) {
if (score > bestScore) {
best = lang;
bestScore = score;
}
}
return bestScore > 0 ? best : 'en';
}
function baseLang(code: string): string {
return String(code || '').toLowerCase().split('-')[0];
}
// ─── Chunking ─────────────────────────────────────────────────
//
// MyMemory's free endpoint caps each request's `q` at 500 characters and
// silently returns only the translated prefix beyond that — which is why long
// emails came back truncated ("…about Sel…"). We split the text into
// line-aware chunks under the limit, translate each, then rejoin so the whole
// body is covered.
const MYMEMORY_CHUNK = 480;
function chunkText(text: string, max: number): string[] {
const chunks: string[] = [];
let cur = '';
const flush = () => {
if (cur) {
chunks.push(cur);
cur = '';
}
};
for (const line of text.split('\n')) {
if (line.length > max) {
flush();
// A single over-long line (e.g. a long URL list): split on words, and
// hard-cut any word that is itself longer than the limit.
let seg = '';
for (const word of line.split(' ')) {
const piece = seg ? seg + ' ' + word : word;
if (piece.length > max) {
if (seg) {
chunks.push(seg);
seg = '';
}
if (word.length > max) {
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
} else {
seg = word;
}
} else {
seg = piece;
}
}
if (seg) chunks.push(seg);
continue;
}
if (cur && cur.length + 1 + line.length > max) flush();
cur = cur ? cur + '\n' + line : line;
}
flush();
return chunks;
}
// ─── Providers ────────────────────────────────────────────────
async function mymemoryRequest(
q: string,
langpair: string,
signal: AbortSignal,
): Promise<string> {
const url = new URL('https://api.mymemory.translated.net/get');
url.searchParams.set('q', q);
url.searchParams.set('langpair', langpair);
const res = await fetch(url.toString(), {
signal,
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
});
const data = (await res.json().catch(() => null)) as
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
| null;
if (!res.ok || !data) {
throw new Error(`MyMemory returned ${res.status}`);
}
const status = Number(data.responseStatus);
if (status && status !== 200) {
throw new Error(data.responseDetails || `MyMemory error ${status}`);
}
const translatedText = data.responseData?.translatedText || '';
if (!translatedText) {
throw new Error('MyMemory returned no translation');
}
return translatedText;
}
async function translateMyMemory(
text: string,
source: string,
target: string,
signal: AbortSignal,
): Promise<TranslateResult> {
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
const tgt = baseLang(target);
// Nothing to do if already in the target language; the plugin skips display.
if (detected === tgt) {
return { translatedText: text, detectedSource: detected };
}
const langpair = `${detected}|${tgt}`;
const chunks = chunkText(text, MYMEMORY_CHUNK);
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
// usually one or two chunks.
const translated: string[] = [];
for (const chunk of chunks) {
translated.push(await mymemoryRequest(chunk, langpair, signal));
}
return { translatedText: translated.join('\n'), detectedSource: detected };
}
async function translateLibre(
text: string,
source: string,
target: string,
signal: AbortSignal,
): Promise<TranslateResult> {
const endpoint = process.env.LIBRETRANSLATE_URL;
if (!endpoint) {
throw new Error('LibreTranslate is not configured on this server');
}
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
const url = endpoint.replace(/\/+$/, '') + '/translate';
const res = await fetch(url, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: text,
source: source || 'auto',
target: baseLang(target),
format: 'text',
...(apiKey ? { api_key: apiKey } : {}),
}),
});
const data = (await res.json().catch(() => null)) as
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
| null;
if (!res.ok || !data) {
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
}
if (!data.translatedText) {
throw new Error(data.error || 'LibreTranslate returned no translation');
}
return {
translatedText: data.translatedText,
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
};
}
// ─── Route ────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
let body: TranslateBody;
try {
body = (await request.json()) as TranslateBody;
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const text = typeof body.text === 'string' ? body.text.trim() : '';
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
if (!text) {
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
}
if (text.length > MAX_CHARS) {
return NextResponse.json(
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
{ status: 413 },
);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const result =
provider === 'libretranslate'
? await translateLibre(text, source, target, controller.signal)
: await translateMyMemory(text, source, target, controller.signal);
return NextResponse.json(result, { status: 200 });
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
}
const message = error instanceof Error ? error.message : 'Translation failed';
return NextResponse.json({ error: message }, { status: 502 });
} finally {
clearTimeout(timeout);
}
}
+1 -1
View File
@@ -70,7 +70,7 @@
}
.dark {
--color-border: rgba(128, 128, 128, 0.3);
--color-border: #262626;
--color-input: #262626;
--color-ring: #d4d4d4;
--color-background: #0a0a0a;
+14 -51
View File
@@ -1,12 +1,5 @@
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";
@@ -32,40 +25,25 @@ 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 =
branded<string>("appName", "") ||
configManager.get<string>("appName") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = branded<string>("appShortName", "") || appName;
const shortName = configManager.get<string>("appShortName") || appName;
const description =
branded<string>("appDescription", "") ||
configManager.get<string>("appDescription") ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
// 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.
// 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.
const sources = configManager.getAllWithSources();
const hasCustomIcon =
!!domainOverrides.pwaIconUrl ||
!!domainOverrides.faviconUrl ||
sources.pwaIconUrl?.source !== "default" ||
sources.faviconUrl?.source !== "default";
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
@@ -95,25 +73,10 @@ export default async function manifest(): Promise<ExtendedManifest> {
background_color: backgroundColor,
icons,
categories: ["productivity"],
// 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" },
];
})(),
screenshots: [
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
],
protocol_handlers: [
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
+2 -34
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } 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,7 +11,6 @@ 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";
@@ -51,7 +50,6 @@ 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;
@@ -73,7 +71,6 @@ export function CalendarSidebarPanel({
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onResetColor,
onShareCalendar,
onCreateEvent,
onClearCalendar,
@@ -96,9 +93,7 @@ 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);
@@ -220,16 +215,6 @@ 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) => {
@@ -317,13 +302,10 @@ 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 || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
@@ -342,13 +324,6 @@ 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]">
@@ -360,13 +335,6 @@ export function CalendarSidebarPanel({
</div>
</ContextMenuSubMenu>
)}
{canResetColor && (
<ContextMenuItem
icon={Shuffle}
label={tMgmt('random_color')}
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canClear && (
<ContextMenuItem
-5
View File
@@ -34,11 +34,6 @@ 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));
}
+11 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations, useLocale } from "next-intl";
import { useTranslations } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
@@ -13,7 +13,6 @@ import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
isOrganizer,
getUserParticipantId,
@@ -102,9 +101,16 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.recurrenceRules?.length) return null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
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;
}
export function EventDetailPopover({
@@ -124,7 +130,6 @@ 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);
@@ -153,7 +158,7 @@ export function EventDetailPopover({
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
+27 -85
View File
@@ -1,13 +1,12 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations, useLocale } from "next-intl";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -76,7 +75,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
return dur;
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
@@ -158,9 +157,16 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.recurrenceRules?.length) return null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
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;
}
export function EventModal({
@@ -180,7 +186,6 @@ 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;
@@ -265,35 +270,8 @@ export function EventModal({
});
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
return event.recurrenceRules[0].frequency as RecurrenceOption;
});
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 [];
@@ -466,9 +444,7 @@ export function EventModal({
data.virtualLocations = null;
}
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
if (recurrence !== "none") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
@@ -535,7 +511,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -761,7 +737,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, locale);
const recurrenceLabel = getRecurrenceLabel(event, t);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -1155,51 +1131,17 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<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}
/>
)}
<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>
<div>
+5 -9
View File
@@ -4,7 +4,6 @@ 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 {
@@ -145,17 +144,14 @@ 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 flex items-center gap-2 ${
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<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>
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</li>
))}
</ul>
-427
View File
@@ -1,427 +0,0 @@
"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>
);
}
+1 -1
View File
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/");
router.push("/mail");
};
const handleOpenEvent = (event: CalendarEvent) => {
@@ -1,106 +0,0 @@
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();
});
});
@@ -1,363 +0,0 @@
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/smime-store', () => {
const state = {
certs: [],
signingEnabled: false,
encryptionEnabled: false,
defaultSigningCertId: null,
defaultEncryptionCertId: 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 { useSmimeStore: 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,
}));
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/smime/smime-sign', () => ({ smimeSign: async () => null }));
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
vi.mock('@/lib/smime/mime-builder', () => ({
buildMimeMessage: () => null,
wrapCmsAsSmimeMessage: () => null,
}));
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();
});
});
@@ -553,10 +553,40 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
// 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.
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
}
};
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
@@ -571,6 +601,7 @@ 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');
@@ -584,6 +615,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -600,6 +632,7 @@ 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 {
File diff suppressed because it is too large Load Diff
+4 -12
View File
@@ -5,7 +5,6 @@ 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 {
@@ -30,17 +29,10 @@ 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). 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}`
);
// Find matching identity (email sent BY the user)
const matchingIdentity = 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;
+3 -7
View File
@@ -100,8 +100,6 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
markThreadAsRead,
threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
@@ -112,9 +110,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
}, [emails, disableThreading, isScheduledView]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -252,12 +250,10 @@ 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, markThreadAsRead]);
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
+49 -247
View File
@@ -83,8 +83,6 @@ import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { ReadReceiptBanner } from "./read-receipt-banner";
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
@@ -609,23 +607,19 @@ function renderClickableRecipients(
}
// Contact sidebar panel that slides in from the right on desktop
export function ContactSidebarPanel({
function ContactSidebarPanel({
email,
contact,
senderName,
onClose,
onAddToContacts,
onEditContact,
}: {
email: string;
contact: ContactCard | null;
senderName?: string;
onClose: () => void;
onAddToContacts?: (email: string, name?: string) => void;
onEditContact?: () => void;
}) {
const t = useTranslations('email_viewer');
const tCommon = useTranslations('common');
const name = contact ? getContactDisplayName(contact) : senderName || null;
const primaryEmail = contact ? getContactPrimaryEmail(contact) : email;
const emails = contact?.emails ? Object.values(contact.emails) : [];
@@ -637,9 +631,9 @@ export function ContactSidebarPanel({
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('contact_sidebar.copied'));
toast.success("Copied!");
} catch {
toast.error(t('contact_sidebar.copy_failed'));
toast.error("Failed to copy");
}
};
@@ -647,11 +641,11 @@ export function ContactSidebarPanel({
<div className="w-[320px] shrink-0 border-l border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground truncate">{t('contact_sidebar.title')}</h3>
<h3 className="text-sm font-semibold text-foreground truncate">Contact</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t('contact_sidebar.close')}
aria-label="Close sidebar"
>
<PanelRightClose className="w-4 h-4 text-muted-foreground" />
</button>
@@ -689,29 +683,19 @@ export function ContactSidebarPanel({
<a
href={`mailto:${primaryEmail}`}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title={t('contact_sidebar.action_email_title')}
title="Send email"
>
<Send className="w-3.5 h-3.5" />
{t('contact_sidebar.action_email')}
Email
</a>
<button
onClick={() => handleCopy(primaryEmail)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title={t('contact_sidebar.action_copy_title')}
title="Copy email"
>
<Copy className="w-3.5 h-3.5" />
{t('contact_sidebar.action_copy')}
Copy
</button>
{contact && onEditContact && (
<button
onClick={onEditContact}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title={t('contact_sidebar.action_edit_title')}
>
<EditIcon className="w-3.5 h-3.5" />
{tCommon('edit')}
</button>
)}
</div>
{/* Details sections */}
@@ -719,7 +703,7 @@ export function ContactSidebarPanel({
<div className="px-4 pb-4 space-y-4">
{/* Emails */}
{emails.length > 0 && (
<SidebarSection icon={Mail} title={t('contact_sidebar.section_emails')}>
<SidebarSection icon={Mail} title="Emails">
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline truncate">
@@ -739,7 +723,7 @@ export function ContactSidebarPanel({
{/* Phones */}
{phones.length > 0 && (
<SidebarSection icon={Phone} title={t('contact_sidebar.section_phones')}>
<SidebarSection icon={Phone} title="Phones">
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
@@ -759,7 +743,7 @@ export function ContactSidebarPanel({
{/* Organizations */}
{orgs.length > 1 && (
<SidebarSection icon={Building} title={t('contact_sidebar.section_organizations')}>
<SidebarSection icon={Building} title="Organizations">
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
@@ -773,7 +757,7 @@ export function ContactSidebarPanel({
{/* Addresses */}
{addresses.length > 0 && (
<SidebarSection icon={MapPin} title={t('contact_sidebar.section_addresses')}>
<SidebarSection icon={MapPin} title="Addresses">
{addresses.map((a, i) => (
<div key={i} className="text-sm text-muted-foreground">
{a.full || a.fullAddress
@@ -788,7 +772,7 @@ export function ContactSidebarPanel({
{/* Notes */}
{notes.length > 0 && (
<SidebarSection icon={StickyNote} title={t('contact_sidebar.section_notes')}>
<SidebarSection icon={StickyNote} title="Notes">
{notes.map((n, i) => (
<p key={i} className="text-sm text-muted-foreground whitespace-pre-wrap">{n.note}</p>
))}
@@ -801,7 +785,7 @@ export function ContactSidebarPanel({
{!contact && (
<div className="px-4 pb-4 text-center space-y-3">
<p className="text-xs text-muted-foreground">
{t('contact_sidebar.not_in_contacts')}
Not in your contacts
</p>
{onAddToContacts && (
<button
@@ -809,7 +793,7 @@ export function ContactSidebarPanel({
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
>
<Mail className="w-3.5 h-3.5" />
{t('contact_sidebar.add_to_contacts')}
Add to contacts
</button>
)}
</div>
@@ -927,7 +911,6 @@ export function EmailViewer({
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []);
@@ -1048,29 +1031,12 @@ export function EmailViewer({
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state. Collapsed/width persist across opens and
// sessions so the panel reopens the way the user last left it.
// Plugin detail sidebar state
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
});
const [detailSidebarWidth, setDetailSidebarWidth] = useState<number>(() => {
if (typeof window === 'undefined') return 280;
try {
const n = parseInt(localStorage.getItem('emailDetailSidebarWidth') ?? '', 10);
return Number.isFinite(n) ? Math.max(200, Math.min(500, n)) : 280;
} catch { return 280; }
});
const detailSidebarWidthRef = useRef(detailSidebarWidth);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
}, [detailSidebarCollapsed]);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ }
}, [detailSidebarWidth]);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
@@ -2227,9 +2193,6 @@ export function EmailViewer({
// Hide inline cid-referenced images when the user has opted to keep them
// out of the attachment list (default on): these are embedded in the body.
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
// Hide machine-readable report parts (MDN read-receipts, DSN bounce
// reports). These are required MIME parts, not real user attachments.
.filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status')
.map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null,
@@ -2873,44 +2836,31 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal.
//
// Background-COLOR containers (bgcolor, background: shorthand) are
// re-inverted so their fill flips to a dark tone, but skipped when they
// wrap media: the inner image already gets its own re-invert, and a filter
// on the container would double-invert it. Nested color containers must NOT
// stack another invert either - each filter toggles the inversion, so an
// odd number of stacked filters (body + outer + inner) lands back on
// light-on-light; the descendant rule disables filter on a color container
// nested inside another.
//
// Background-IMAGE containers are different: a real photo and whatever is
// composited over it (headline text, logos) was designed by the sender to
// read together, so the whole subtree should return to its original
// light-mode appearance - even when it wraps media. We therefore re-invert
// these unconditionally (no :has guard) and cancel the per-image re-invert
// for media inside them, otherwise those images would flip back to inverted.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
// Nested bgcolor containers must NOT add another invert layer: each filter
// toggles the inversion, so an odd number of stacked filters (e.g. body +
// outer bgcolor table + inner bgcolor table) produces an inverted result -
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
[background]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
:where([style*="background:"], [bgcolor])
:where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
:where([style*="background-image"], [style*="background:"], [background], [bgcolor])
:where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
filter: none !important;
}
[style*="background-image"],
[background] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]),
[background] :where(img, video, svg, canvas, object, embed, input[type="image"]) {
filter: none;
}
` : '';
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
@@ -2943,31 +2893,17 @@ export function EmailViewer({
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<style>
/* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize -
collapses the measured height and clips everything below the fold. The
height reset is repeated in a trailing <style> after the body content
(see below): the email's own <style> is injected inside our <body> and so
lands later in source order, where - at equal specificity and !important -
it would otherwise win the cascade. */
/* overflow-y stays hidden so our scrollHeight-based height measurement works;
overflow-x is auto on the body so an intrinsically wide table (e.g. a
20-column data table) can scroll horizontally instead of being crushed to
fit - the latter wraps header text to one character per line, which reads
as 90deg-rotated vertical headers (issue #409). */
html { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
html, body { overflow: hidden; }
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
/* break-word (not anywhere): break only over-long single words, and keep each
word's min-content width so columns are not collapsed to a single char. */
td, th { overflow-wrap: break-word; }
td, th { overflow-wrap: anywhere; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
</style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document.
@@ -3106,13 +3042,10 @@ export function EmailViewer({
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert
// even when media is nested, then cancel the per-image
// re-invert on that media so it isn't left double-inverted.
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
htmlEl.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(m => { (m as HTMLElement).style.filter = 'none'; });
// Only re-invert if this container doesn't have media children
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
@@ -3319,103 +3252,6 @@ export function EmailViewer({
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
: false;
// ── Read receipt (MDN, RFC 8098) ──────────────────────────────
// Detect a Disposition-Notification-To request on the open message. The
// header is parsed into email.headers by the client; look it up
// case-insensitively and extract the bare address.
const readReceiptRequestedBy = useMemo(() => {
const headers = email?.headers as Record<string, string | string[]> | undefined;
if (!headers) return null;
let raw: string | undefined;
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'disposition-notification-to') {
const v = headers[key];
raw = Array.isArray(v) ? v[0] : v;
break;
}
}
if (!raw) return null;
const m = raw.match(/<([^>]+)>/);
const addr = (m ? m[1] : raw).trim();
return addr || null;
}, [email?.headers]);
// The identity whose address received the original message — the MDN is sent
// "from" that address. Falls back to the primary identity.
const receiptIdentity = useMemo(() => {
if (!identities?.length) return null;
const recipients = [...(email?.to || []), ...(email?.cc || [])]
.map(r => r.email?.toLowerCase())
.filter(Boolean);
return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0];
}, [identities, email?.to, email?.cc]);
const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true;
const [mdnHandledLocally, setMdnHandledLocally] = useState(false);
useEffect(() => { setMdnHandledLocally(false); }, [email?.id]);
// Only offer the receipt for mail you're actually reading in a "received"
// location. Suppress your own copies (sent/drafts), discarded mail (trash),
// and spam (junk) - never confirm your address to spammers. Inbox, Archive
// and user folders all qualify.
const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || '');
const shouldOfferReadReceipt =
!!readReceiptRequestedBy &&
!mdnAlreadyHandled &&
!mdnHandledLocally &&
readReceiptResponse !== 'never' &&
inReceiptEligibleFolder &&
!isDraft &&
!!receiptIdentity;
const sendReadReceiptNow = useCallback(async (automatic: boolean) => {
if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return;
const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id);
try {
await client.sendReadReceipt({
to: readReceiptRequestedBy,
fromEmail: receiptIdentity.email,
fromName: receiptIdentity.name,
identityId: rawId ?? receiptIdentity.id,
originalMessageId: email.messageId,
originalSubject: email.subject,
originalRecipient: receiptIdentity.email,
automatic,
subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }),
humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }),
});
await client.setKeyword(email.id, '$mdnsent');
} catch (err) {
// Surface the failure instead of silently resetting the banner so we can
// see which step (upload / import / submission) failed.
console.error('Read-receipt (MDN) send failed:', err);
toast.error(t('read_receipt.send_failed'), {
message: err instanceof Error ? err.message : String(err),
});
throw err;
}
}, [client, email, readReceiptRequestedBy, receiptIdentity, t]);
const ignoreReadReceipt = useCallback(async () => {
setMdnHandledLocally(true);
if (client && email) {
// $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the
// request is suppressed everywhere - not just locally.
try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ }
}
}, [client, email]);
// "always" mode: auto-send the MDN once when the message is opened.
const autoMdnRef = useRef<string | null>(null);
useEffect(() => {
if (readReceiptResponse !== 'always') return;
if (!shouldOfferReadReceipt || !email) return;
if (autoMdnRef.current === email.id) return;
autoMdnRef.current = email.id;
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
return (
@@ -4284,8 +4120,8 @@ export function EmailViewer({
"bg-background border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}>
<div className="px-2 sm:px-4 lg:px-6 h-14 flex items-center">
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2 w-full">
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-2">
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
{renderToolbarItems(true)}
</div>
</div>
@@ -4875,27 +4711,9 @@ export function EmailViewer({
<section className="min-w-0">
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{auth?.spf && (() => {
// When multiple identities (HELO + MAIL FROM) were
// evaluated, list each result in the tooltip for full
// transparency; the chip itself shows the most severe.
const breakdown = auth.spf.all && auth.spf.all.length > 1
? auth.spf.all
.map((r) => {
const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF';
return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`;
})
.join('\n')
: null;
return (
<AuthChip
name="SPF"
result={auth.spf.result}
extra={auth.spf.domain}
tooltip={breakdown ? `${t('authentication.tooltip_spf')}\n\n${breakdown}` : t('authentication.tooltip_spf')}
/>
);
})()}
{auth?.spf && (
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
)}
{auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)}
@@ -5128,10 +4946,9 @@ export function EmailViewer({
error={smimeUnlockError}
/>
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{/* Unified Notification Banner - External Content + Calendar Invitation */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
@@ -5186,17 +5003,6 @@ export function EmailViewer({
{/* Read-receipt (MDN) request banner — only in "ask" mode */}
{readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && (
<div className="py-1">
<ReadReceiptBanner
requestedBy={readReceiptRequestedBy}
onSend={() => sendReadReceiptNow(false)}
onIgnore={ignoreReadReceipt}
/>
</div>
)}
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
<div className="py-1">
@@ -5854,10 +5660,6 @@ export function EmailViewer({
return allRecipients.find(r => r.email.toLowerCase() === contactSidebarEmail.toLowerCase())?.name;
})()}
onClose={() => setContactSidebarEmail(null)}
onEditContact={sidebarContact ? () => {
router.push(`/contacts?contactId=${sidebarContact.id}&view=edit`);
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
const client = useAuthStore.getState().client;
-187
View File
@@ -1,187 +0,0 @@
"use client";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
/**
* QuotedHtml an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
* and is NEVER parsed into the ProseMirror schema, so layout-heavy emails
* (nested tables, MJML, Outlook divs) survive a reply/forward 1:1.
*
* Behaviour:
* - To ProseMirror it's a single atomic block: Backspace at the boundary or
* Ctrl+A + Delete removes the whole quote in one go ("wie es sich gehört").
* - Its NodeView renders an inner `contentEditable` region so the user can
* still redact text inside the quote. Inner edits are synced back into the
* `html` attribute (without polluting the undo history).
*
* Serialization: ProseMirror's DOM serializer can't emit an atom's inner raw
* HTML, so use `serializeEditorContent(editor)` (below) instead of
* `editor.getHTML()` to read the composer body for sending/draft-saving.
*/
export const QuotedHtml = TiptapNode.create({
name: "quotedHtml",
group: "block",
atom: true,
selectable: true,
draggable: false,
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
isolating: true,
addAttributes() {
return {
html: {
default: "",
// Capture the verbatim inner HTML when parsing. Because the node is
// an atom, ProseMirror does NOT descend into the children, so they
// never hit the schema.
parseHTML: (el) => el.innerHTML,
// Not rendered as an attribute - the real content round-trips via the
// custom serializer. renderHTML below only needs the wrapper.
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: `div[${QUOTED_HTML_MARKER}]` }];
},
renderHTML({ HTMLAttributes }) {
// Only used for ProseMirror's internal/clipboard round-trip. The send /
// draft path uses serializeEditorContent() which inlines attrs.html.
return ["div", mergeAttributes(HTMLAttributes, { [QUOTED_HTML_MARKER]: "" })];
},
addNodeView() {
return ({ node, editor, getPos }) => {
// Host element ProseMirror manages. A subtle left border echoes the
// classic email quote bar without touching the quoted content's styling.
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
dom.style.cssText =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
// resets) would otherwise cascade INTO the quote and destroy its layout
// - even though the verbatim HTML serializes/sends perfectly. Shadow DOM
// isolates both directions: only the browser's UA defaults + the email's
// own inline styles apply, so the in-editor rendering matches the sent
// mail 1:1.
const shadow = dom.attachShadow({ mode: "open" });
const inner = document.createElement("div");
inner.contentEditable = "true";
inner.style.cssText = "outline:none;";
inner.innerHTML = node.attrs.html || "";
shadow.appendChild(inner);
// Track focus via focusin/focusout: inside a shadow root,
// document.activeElement is retargeted to the host, so we can't rely on
// it to detect "is the user editing in here".
let focused = false;
inner.addEventListener("focusin", () => {
focused = true;
});
inner.addEventListener("focusout", () => {
focused = false;
});
// Sync inner edits back into the node attribute. Coalesced via rAF so a
// burst of keystrokes is one transaction; addToHistory:false keeps
// redaction edits out of the editor's undo stack. The `input` event is
// composed and crosses the shadow boundary, so this listener fires.
let frame = 0;
const syncBack = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (typeof getPos !== "function") return;
const pos = getPos();
if (pos == null) return;
const current = inner.innerHTML;
if (current === node.attrs.html) return;
editor.view.dispatch(
editor.view.state.tr
.setNodeAttribute(pos, "html", current)
.setMeta("addToHistory", false)
);
});
};
inner.addEventListener("input", syncBack);
return {
dom,
// ProseMirror must not try to reconcile the foreign shadow content.
ignoreMutation: () => true,
// Events originating inside the island are retargeted to the host
// (`dom`) once they cross the shadow boundary, so dom.contains(target)
// is true for them → let the native shadow contentEditable handle
// them. Events from the surrounding doc (boundary Backspace,
// Ctrl+A+Delete) target other elements → fall through to ProseMirror
// so whole-block deletion still works.
stopEvent: (event) => {
const target = event.target as Node | null;
return !!target && dom.contains(target);
},
update: (updatedNode) => {
if (updatedNode.type.name !== "quotedHtml") return false;
// Don't clobber the caret while the user is redacting inside.
if (!focused && inner.innerHTML !== updatedNode.attrs.html) {
inner.innerHTML = updatedNode.attrs.html || "";
}
return true;
},
destroy: () => {
cancelAnimationFrame(frame);
inner.removeEventListener("input", syncBack);
},
};
};
},
});
/**
* Serialize the composer document to HTML for sending / draft-saving.
*
* Use this INSTEAD of `editor.getHTML()`: ProseMirror's DOM serializer cannot
* emit the raw inner HTML of an atom node, so it would drop the quoted body.
* Here we walk the top-level nodes, inline the quote node's verbatim `html`,
* and serialize everything else normally.
*/
export function serializeEditorContent(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema);
const parts: string[] = [];
editor.state.doc.forEach((node) => {
if (node.type.name === "quotedHtml") {
// Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft
// round-trips: re-opening parses this back into a QuotedHtml node
// instead of letting the schema mangle the raw table layout again.
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
return;
}
const fragment = serializer.serializeNode(node);
const tmp = document.createElement("div");
tmp.appendChild(fragment);
parts.push(tmp.innerHTML);
});
return parts.join("");
}
/**
* Build the editor-content wrapper that the composer prepends/appends so the
* quoted original becomes a single QuotedHtml node. The inner HTML must be
* pre-sanitized (scripts/styles/head stripped, cid: images rewritten).
*
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
}
-60
View File
@@ -1,60 +0,0 @@
'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>
);
}
+3 -11
View File
@@ -16,7 +16,6 @@ import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { cn } from "@/lib/utils";
import {
Bold,
@@ -232,9 +231,6 @@ export function RichTextEditor({
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
},
}),
// Quoted/forwarded original email body - held verbatim as an atomic
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
QuotedHtml,
],
content,
editorProps: {
@@ -285,18 +281,14 @@ export function RichTextEditor({
},
},
onUpdate: ({ editor }) => {
// serializeEditorContent (not getHTML) so the verbatim quoted-original
// HTML held in the QuotedHtml atom node is emitted intact.
onChange(serializeEditorContent(editor));
onChange(editor.getHTML());
},
immediatelyRender: false,
});
// 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).
// Sync external content changes (e.g. template application)
useEffect(() => {
if (editor && content !== serializeEditorContent(editor)) {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content, { emitUpdate: false });
}
}, [content, editor]);
-110
View File
@@ -1,110 +0,0 @@
"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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// 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>
);
}
+3 -84
View File
@@ -11,7 +11,7 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu, Users, Share2,
Menu,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -26,9 +26,6 @@ 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";
@@ -98,14 +95,6 @@ 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"]);
@@ -241,33 +230,6 @@ 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>;
@@ -380,25 +342,11 @@ 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);
@@ -1492,9 +1440,8 @@ 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 flex items-center justify-center gap-1" title={resource.name}>
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
<span className="text-xs truncate w-full text-center" title={resource.name}>
{resource.name}
</span>
</div>
))}
@@ -1643,7 +1590,6 @@ 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">
@@ -1751,19 +1697,6 @@ 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"
@@ -2003,20 +1936,6 @@ 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>
);
}
+15 -156
View File
@@ -1,48 +1,10 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2, ExternalLink } from "lucide-react";
import { X, Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
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;
}
import { getFilePreviewKind } from "@/lib/file-preview";
interface FilePreviewModalProps {
name: string;
@@ -149,54 +111,6 @@ 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;
@@ -206,8 +120,6 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
setContent(null);
setObjectUrl(null);
setCanOpenInNewTab(false);
setEmlContent(null);
setLoading(true);
setError(false);
setResolvedFileType(getFilePreviewKind(name));
@@ -224,36 +136,9 @@ 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 {
// 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));
}
revokeUrl = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(revokeUrl);
}
} catch {
if (!cancelled) setError(true);
@@ -288,18 +173,6 @@ 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>
@@ -338,10 +211,6 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div>
)}
{!loading && !error && fileType === "eml" && emlContent && (
<EmlPreview message={emlContent} />
)}
{!loading && !error && fileType === "image" && objectUrl && (
<img
src={objectUrl}
@@ -362,29 +231,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
/>
)}
{!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}
{!loading && !error && fileType === "pdf" && objectUrl && (
<object
data={objectUrl}
type="application/pdf"
className="w-full max-w-5xl h-full rounded-lg bg-white"
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"
aria-label={name}
onClick={(e) => e.stopPropagation()}
>
<PdfMobileViewer url={objectUrl} />
</div>
<Button onClick={() => void onDownload()}>
<Download className="w-4 h-4 mr-2" />
{t("download")}
</Button>
</object>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
-39
View File
@@ -8,7 +8,6 @@ import {
ChevronRight,
ChevronDown,
Home,
Share2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
@@ -30,8 +29,6 @@ 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());
@@ -84,8 +81,6 @@ 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]);
@@ -185,40 +180,6 @@ 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>
);
-223
View File
@@ -1,223 +0,0 @@
"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>
);
}
+25 -114
View File
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
}
const ALL_FIELDS: FilterConditionField[] = [
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
"from", "to", "cc", "subject", "header", "size", "body",
];
const TEXT_COMPARATORS: FilterComparator[] = [
@@ -36,14 +36,6 @@ 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",
];
@@ -55,27 +47,6 @@ 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: "" };
}
@@ -126,23 +97,9 @@ export function FilterRuleModal({
return;
}
// 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 };
});
const validConditions = conditions.filter(
(c) => c.value.trim()
);
if (validConditions.length === 0) {
toast.error(t("validation_empty_conditions"));
return;
@@ -172,27 +129,15 @@ export function FilterRuleModal({
prev.map((c, i) => {
if (i !== index) return c;
const updated = { ...c, ...updates };
// 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 === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
updated.comparator = "greater_than";
}
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
updated.comparator = "contains";
}
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;
})
);
@@ -336,58 +281,24 @@ export function FilterRuleModal({
className={selectClass}
aria-label={t("comparators.contains")}
>
{comparatorsFor(condition.field).map((c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
))}
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
(c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
)
)}
</select>
{/* 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"}
/>
)}
<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"}
/>
<button
type="button"
+1 -1
View File
@@ -8,7 +8,7 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
const SIGNATURE_MAX_BYTES = 2047;
const utf8Encoder = new TextEncoder();
+1 -1
View File
@@ -546,7 +546,7 @@ export function NavigationRail({
})}
{/* Manage apps button */}
{sidebarAppsEnabled && onManageApps && (
{onManageApps && (
<button
onClick={onManageApps}
className={cn(
+2 -4
View File
@@ -77,11 +77,9 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
// 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",
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
isHorizontal ? "h-1 -my-0.5 cursor-row-resize bg-border" : "w-1 -mx-0.5 cursor-col-resize",
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
className
)}
>
+24 -42
View File
@@ -33,7 +33,6 @@ import {
NotebookPen,
CalendarClock,
BellOff,
Mails,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -77,8 +76,6 @@ interface SidebarProps {
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
showAllMailMailbox?: boolean;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -681,7 +678,6 @@ export function Sidebar({
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
showAllMailMailbox = false,
className,
multiAccountMode = false,
accountMailboxes,
@@ -908,11 +904,11 @@ export function Sidebar({
};
const openFolderSettings = () => {
try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ }
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
router.push('/settings');
};
const openKeywordSettings = () => {
try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ }
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
router.push('/settings');
};
@@ -946,34 +942,30 @@ 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 && (
// 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>
<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>
<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" />
)}
</div>
{!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
)}
</div>
)}
@@ -982,16 +974,6 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
{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 && (
<div>
<SidebarSectionHeader
+9 -22
View File
@@ -7,19 +7,6 @@
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);
@@ -63,9 +50,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
>
<div
style={{
background: 'var(--color-popover, #fff)',
color: 'var(--color-popover-foreground, #0f172a)',
border: '1px solid var(--color-border, #e2e8f0)',
background: 'var(--background, #fff)',
color: 'var(--foreground, #0f172a)',
border: '1px solid var(--border, #e2e8f0)',
borderRadius: 12,
padding: 20,
maxWidth: 480,
@@ -76,8 +63,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(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{renderMessage(current.message)}
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
{current.message}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{current.kind === 'confirm' && (
@@ -91,7 +78,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: '1px solid var(--color-border, #e2e8f0)',
border: '1px solid var(--border, #e2e8f0)',
background: 'transparent',
color: 'inherit',
}}
@@ -110,14 +97,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
fontWeight: 500,
cursor: 'pointer',
border: '1px solid transparent',
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
background: current.danger ? '#dc2626' : '#3b82f6',
color: '#fff',
}}
>
{confirmLabel}
</button>
</div>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
From plugin: {current.pluginId}
</div>
</div>
-12
View File
@@ -10,8 +10,6 @@ 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;
@@ -71,16 +69,6 @@ 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}`} />;
}
+1 -19
View File
@@ -26,7 +26,6 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
@@ -93,24 +92,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
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 };
});
}
}
}
await fetchEmails(client, selectedMailbox);
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
+3 -4
View File
@@ -10,7 +10,6 @@ import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
interface ProEmailTabBodyProps {
tabId: string;
@@ -105,7 +104,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
initialDraftText: draftText,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
title: `Re: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
@@ -117,7 +116,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
mode: 'replyAll',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
title: `Re: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
@@ -129,7 +128,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
mode: 'forward',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
-2
View File
@@ -9,7 +9,6 @@ import deMessages from '@/locales/de/common.json';
import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import frMessages from '@/locales/fr/common.json';
import huMessages from '@/locales/hu/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
@@ -30,7 +29,6 @@ const ALL_MESSAGES = {
en: enMessages,
es: esMessages,
fr: frMessages,
hu: huMessages,
it: itMessages,
ja: jaMessages,
ko: koMessages,
+6 -8
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
@@ -18,7 +17,6 @@ export function PWAInstallPrompt() {
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
const t = useTranslations("pwa_install");
useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return;
@@ -86,17 +84,17 @@ export function PWAInstallPrompt() {
)}
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
{t("title", { appName })}
Install {appName}
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t("description")}
Install our app for quick access and offline support.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label={t("dismiss_aria")}
aria-label="Dismiss install prompt"
>
<X className="w-4 h-4" />
</button>
@@ -107,20 +105,20 @@ export function PWAInstallPrompt() {
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
{t("not_now")}
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
{t("install")}
Install
</button>
</div>
<button
onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
>
{t("dont_remind")}
Don&apos;t remind me again
</button>
</div>
</div>
@@ -1,18 +1,15 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal, QrCode } from 'lucide-react';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
@@ -641,156 +638,14 @@ function EmailClientSection() {
);
}
// Cross-device QR login. A signed-in (OAuth/SSO) session mints a short-lived
// pairing code via /api/auth/pair/create; we render it as a QR that the mobile
// app scans to sign in without re-typing credentials. The QR payload carries
// only the server URL and the one-time code — never tokens.
function LinkDeviceSection() {
const t = useTranslations('settings.security');
const params = useParams();
const locale = params.locale as string;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [remaining, setRemaining] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasGenerated, setHasGenerated] = useState(false);
// Tick the countdown down to zero, then drop the (now useless) QR so the
// user is nudged to generate a fresh one.
useEffect(() => {
if (remaining <= 0) {
setQrDataUrl(null);
return;
}
const timer = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
return () => clearInterval(timer);
}, [remaining]);
// Send the user to the IdP for a fresh login (prompt=login). On return the
// callback page sets the pairing re-auth proof and bounces back here, where
// the resume effect below re-runs generate().
const startReauth = useCallback(async () => {
try {
sessionStorage.setItem('pair_reauth_resume', '1');
} catch { /* sessionStorage unavailable */ }
const prefix = getPathPrefix(locale);
const redirectUri = `${window.location.origin}${prefix}/${locale}/auth/callback`;
const res = await apiFetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ redirect_uri: redirectUri, locale, purpose: 'reauth' }),
});
if (!res.ok) {
setError(t('link_device.error'));
return;
}
const { authorize_url } = await res.json();
window.location.href = authorize_url;
}, [locale, t]);
// `fromResume` guards against a redirect loop: if we just completed re-auth
// and pair/create still demands it, surface an error instead of bouncing to
// the IdP again.
const generate = useCallback(async (fromResume = false) => {
setLoading(true);
setError(null);
try {
// Pair the account whose session cookie we'll actually refresh — the
// active account's slot.
const slot = useAccountStore.getState().getActiveAccount()?.cookieSlot ?? 0;
const res = await apiFetch('/api/auth/pair/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ slot }),
});
if (!res.ok) {
const errBody = await res.json().catch(() => null);
if (res.status === 401 && errBody?.error === 'reauth_required' && !fromResume) {
await startReauth();
return;
}
setError(t('link_device.error'));
return;
}
const data = await res.json();
const code = data.pairing_code as string;
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 120;
// The phone redeems the code against THIS webmail (where the pairing
// record lives), so the QR carries the webmail base — origin plus any
// mount prefix — not the JMAP server URL. The JMAP server_url comes back
// in the redeem response.
const webmailBase = `${window.location.origin}${getPathPrefix()}`;
const payload = `bulwarkmail://pair?server=${encodeURIComponent(webmailBase)}&code=${encodeURIComponent(code)}`;
const dataUrl = await QRCode.toDataURL(payload, { width: 240, margin: 1 });
setQrDataUrl(dataUrl);
setRemaining(expiresIn);
setHasGenerated(true);
} catch {
setError(t('link_device.error'));
} finally {
setLoading(false);
}
}, [t, startReauth]);
// Auto-resume after returning from the step-up re-auth round-trip.
useEffect(() => {
let resume = false;
try {
resume = sessionStorage.getItem('pair_reauth_done') === '1';
if (resume) sessionStorage.removeItem('pair_reauth_done');
} catch { /* sessionStorage unavailable */ }
if (resume) void generate(true);
}, [generate]);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('link_device.description')}</p>
{qrDataUrl && remaining > 0 && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
<div className="flex justify-center">
<img src={qrDataUrl} alt="Pairing QR code" className="rounded bg-white p-2" />
</div>
<p className="text-xs text-muted-foreground text-center">{t('link_device.instructions')}</p>
<p className="text-[11px] text-muted-foreground text-center">
{t('link_device.expires_in', { seconds: remaining })}
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
</div>
);
}
export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore();
const { isAuthenticated, authMode, client } = useAuthStore();
const { isAuthenticated, authMode } = useAuthStore();
const isOAuth = authMode === 'oauth';
// Wait for `client` before probing. On reload the persisted `isAuthenticated`
// flips true before the async OAuth reconnect sets `client`; probing in that
// window reads a null client, decides the server isn't Stalwart, and caches
// that wrong verdict. Gating on `client` (which is set only after connect()
// populates the session capabilities) makes the probe run with real data.
useEffect(() => {
if (isAuthenticated && client && isStalwart === null) {
if (isAuthenticated && isStalwart === null) {
probe().then((detected) => {
if (detected) {
if (isOAuth) {
@@ -801,7 +656,7 @@ export function AccountSecuritySettings() {
}
});
}
}, [isAuthenticated, client, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
}, [isAuthenticated, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
if (isProbing) {
return (
@@ -815,25 +670,9 @@ export function AccountSecuritySettings() {
}
if (isStalwart === false) {
// Even when Stalwart account-management isn't exposed (common for OAuth
// sessions, whose tokens may lack the management capability), the
// cross-device mobile pairing still works — it only needs the OAuth
// refresh-token cookie, not `urn:stalwart:jmap`. So surface the QR linker
// for OAuth sessions and show the "not available" note for the rest.
// Use t.raw (not t) because the message is hand-injected HTML; passing it
// through t() makes next-intl try to parse the <a> tag and throw
// INVALID_TAG.
return (
<SettingsSection title={t('title')} description={t('description')}>
{isOAuth ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
)}
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
</SettingsSection>
);
}
@@ -867,8 +706,6 @@ export function AccountSecuritySettings() {
<>
<div className="border-t border-border" />
<EmailClientSection />
<div className="border-t border-border" />
<LinkDeviceSection />
</>
)}
+3 -71
View File
@@ -1,13 +1,11 @@
"use client";
import { useState, useRef, useCallback, useMemo } from 'react';
import { useState, useRef, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react';
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import type { SharedAccount } from '@/lib/jmap/types';
import { SettingsSection, SettingItem } from './settings-section';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -19,30 +17,12 @@ function hostnameOf(serverUrl: string): string {
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
}
// First scoped settings tab to land on for a shared account, by capability.
// Mirrors the scoped-tab gating in the settings page. null = nothing editable.
function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
if (caps.sieve) return 'filters';
if (caps.mail) return 'vacation';
if (caps.calendars) return 'calendar';
if (caps.contacts) return 'contacts';
return null;
}
export function AccountSettings() {
const t = useTranslations('settings.account');
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
const sharedAccounts = useMemo<SharedAccount[]>(
() => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary),
[client],
);
const { quota } = useEmailStore();
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
@@ -102,16 +82,6 @@ export function AccountSettings() {
router.push(`/login?mode=add-account` as never);
}, [router]);
// Enter scoped settings mode for a shared/group account: set the managed
// account, then steer the settings panel to its first editable tab via the
// existing 'settings-tab-change' event the page already listens for.
const handleManageShared = useCallback((account: SharedAccount) => {
const tab = firstScopedTab(account.capabilities);
if (!tab) return;
setManagedAccount(account);
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab }));
}, [setManagedAccount]);
return (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
@@ -227,44 +197,6 @@ export function AccountSettings() {
</div>
</SettingsSection>
)}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
<div className="space-y-2">
{sharedAccounts.map((acc) => {
const editable = firstScopedTab(acc.capabilities) !== null;
return (
<button
key={acc.id}
type="button"
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
<Avatar
name={acc.name}
size="sm"
className="w-9 h-9 text-sm flex-shrink-0"
disableFavicon
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{acc.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t('shared_accounts.shared_label')}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
</SettingsSection>
)}
</div>
);
}
@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
@@ -72,7 +71,6 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
@@ -207,13 +205,9 @@ export function AddressBookManagementSettings() {
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{/* In scoped mode (managing a shared account) hide the user's own
address books and show only the managed account's group. */}
{(managedAccountId ? [] : personal).map(renderBook)}
{personal.map(renderBook)}
{Array.from(sharedGroups.entries())
.filter(([accountId]) => !managedAccountId || accountId === managedAccountId)
.map(([accountId, group]) => (
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
@@ -229,9 +223,6 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
{/* Contact categories come from the active account's contacts, not the
shared account, so hide them while scoped to a shared account. */}
{!managedAccountId && (
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
@@ -276,7 +267,6 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
)}
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
@@ -14,9 +14,26 @@ import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
function CalendarColorPicker({
value,
@@ -138,7 +155,6 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
@@ -166,8 +182,6 @@ export function CalendarManagementSettings() {
const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription');
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef<HTMLDivElement>(null);
// Load calendars if not yet loaded
@@ -315,15 +329,6 @@ export function CalendarManagementSettings() {
const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return;
// Shared calendars: recolor locally only - the viewer usually can't write
// the owner's calendar and it'd recolor it for everyone (see #345).
const cal = calendars.find((c) => c.id === calendarId);
if (cal?.isShared) {
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
toast.success(t('color_updated'));
setColorPickerId(null);
return;
}
try {
await updateCalendar(client, calendarId, { color });
toast.success(t('color_updated'));
@@ -390,11 +395,8 @@ export function CalendarManagementSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
.filter(cal => !managedAccountId || cal.accountId === managedAccountId)
.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
const color = cal.color || '#3b82f6';
if (editingId === cal.id) {
return (
@@ -22,15 +22,12 @@ export function ComposingSettings() {
const {
autoSelectReplyIdentity,
plainTextMode,
attachmentReminderEnabled,
attachmentReminderKeywords,
sendDelaySeconds,
subAddressDelimiter,
signaturePosition,
signatureSeparatorEnabled,
requestReadReceiptDefault,
readReceiptResponse,
updateSetting,
} = useSettingsStore();
const { client } = useAuthStore();
@@ -45,13 +42,6 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
@@ -88,25 +78,6 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('request_read_receipt.label')} description={t('request_read_receipt.description')}>
<ToggleSwitch
checked={requestReadReceiptDefault}
onChange={(checked) => updateSetting('requestReadReceiptDefault', checked)}
/>
</SettingItem>
<SettingItem label={t('read_receipt_response.label')} description={t('read_receipt_response.description')}>
<Select
value={readReceiptResponse}
onChange={(value) => updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')}
options={[
{ value: 'ask', label: t('read_receipt_response.ask') },
{ value: 'always', label: t('read_receipt_response.always') },
{ value: 'never', label: t('read_receipt_response.never') },
]}
/>
</SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
+7 -71
View File
@@ -12,9 +12,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { FilterRule } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import {
Plus,
GripVertical,
@@ -38,17 +36,7 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
const conditions = rule.conditions.slice(0, 2).map((c) => {
const field = t(`condition_fields.${c.field}`);
const comparator = t(`comparators.${c.comparator}`);
// has_any is a no-value test ("attachment is present"); appending
// `""` would look broken in the summary line.
if (c.field === "attachment" && c.comparator === "has_any") {
return `${field} ${comparator}`;
}
// Multi-value conditions render as "a" / "b" / "c" with the locale's
// OR-glue between items so the line still reads as natural language.
const valueStr = Array.isArray(c.value)
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
: `"${c.value}"`;
return `${field} ${comparator} ${valueStr}`;
return `${field} ${comparator} "${c.value}"`;
});
const joiner = rule.matchType === "all" ? t("and") : t("or");
@@ -109,22 +97,7 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
<span className="text-muted-foreground">{comparator}</span>
{!(c.field === "attachment" && c.comparator === "has_any") && (
<span className="text-foreground">
{Array.isArray(c.value)
? c.value.map((v, k) => (
<span key={k}>
{k > 0 && (
<span className="text-muted-foreground/70 italic mx-0.5">
{t("or")}
</span>
)}
{v}
</span>
))
: <>{c.value}</>}
</span>
)}
<span className="text-foreground">{c.value}</span>
</span>
</span>
);
@@ -159,7 +132,7 @@ export function FilterSettings() {
const t = useTranslations("settings.filters");
const tNotifications = useTranslations("notifications");
const { client } = useAuthStore();
const storeMailboxes = useEmailStore((s) => s.mailboxes);
const mailboxes = useEmailStore((s) => s.mailboxes);
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -172,7 +145,7 @@ export function FilterSettings() {
isOpaque,
rawScript,
vacationSettings,
selectAccount,
fetchFilters,
saveFilters,
addRule,
updateRule,
@@ -184,41 +157,7 @@ export function FilterSettings() {
validateScript,
} = useFilterStore();
// Scoped to a shared/group account when the settings panel is managing one.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const isPrimaryAccount = !managedAccountId;
// Folders offered as "move to" targets in the rule editor must belong to the
// account whose filters we're editing. For a shared account, fetch that
// account's mailboxes (the email store only holds the active account's). They
// are this account's own folders within its Sieve context, so present them as
// non-shared — otherwise the rule modal filters them out (it drops isShared
// mailboxes, which are normally other accounts' folders merged into the view).
const [scopedMailboxes, setScopedMailboxes] = useState<Mailbox[]>([]);
useEffect(() => {
if (!client || !managedAccountId) {
setScopedMailboxes([]);
return;
}
let cancelled = false;
void client
.getMailboxes(managedAccountId)
.then((mbs) => {
if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false })));
})
.catch(() => {
if (!cancelled) setScopedMailboxes([]);
});
return () => { cancelled = true; };
}, [client, managedAccountId]);
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
// Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the
// "vacation active" banner only applies when editing the personal account.
const vacationEnabled =
isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled);
const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled;
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
@@ -230,12 +169,9 @@ export function FilterSettings() {
useEffect(() => {
if (client && isSupported) {
// Always pass a concrete account id (managed shared account, or the
// primary Sieve account) so switching never falls back to a stale
// previously-selected account.
void selectAccount(client, managedAccountId ?? client.getSieveAccountId());
void fetchFilters(client);
}
}, [client, isSupported, managedAccountId, selectAccount]);
}, [client, isSupported, fetchFilters]);
const handleToggle = useCallback(
async (ruleId: string) => {
+2 -69
View File
@@ -2,7 +2,6 @@
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, Folder } from 'lucide-react';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
@@ -118,27 +117,11 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
// Own (non-shared) folders and the current All Mail selection. `null` =
// never configured, which defaults to all non-special (no-role) folders.
const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]);
const allMailSelected = new Set(
allMailFolderIds === null
? ownMailboxes.filter(m => !m.role).map(m => m.id)
: allMailFolderIds
);
const toggleAllMailFolder = (id: string) => {
const next = new Set(allMailSelected);
if (next.has(id)) next.delete(id);
else next.add(id);
updateSetting('allMailFolderIds', ownMailboxes.filter(m => next.has(m.id)).map(m => m.id));
};
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -226,56 +209,6 @@ export function LayoutSettings() {
</div>
)}
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem
label={t('all_mail.label')}
description={t('all_mail.description')}
locked={isSettingLocked('enableAllMailView')}
>
<ToggleSwitch
checked={enableAllMailView}
onChange={(v) => updateSetting('enableAllMailView', v)}
/>
</SettingItem>
)}
{allMailViewAllowed && enableAllMailView && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
</div>
{ownMailboxes.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p>
) : (
<div className="space-y-0.5">
{ownMailboxes.map((mb) => {
const checked = allMailSelected.has(mb.id);
return (
<button
key={mb.id}
type="button"
onClick={() => toggleAllMailFolder(mb.id)}
className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-left"
role="checkbox"
aria-checked={checked}
>
<span className={cn(
"flex items-center justify-center w-4 h-4 rounded border flex-shrink-0 transition-colors",
checked ? "bg-primary border-primary text-primary-foreground" : "border-border"
)}>
{checked && <Check className="w-3 h-3" />}
</span>
<Folder className={cn("w-4 h-4 flex-shrink-0", mb.role ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm text-foreground truncate">{mb.name}</span>
</button>
);
})}
</div>
)}
</div>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<ToggleSwitch
checked={proInterface}
+5 -25
View File
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -40,30 +40,16 @@ export function NotificationSettings() {
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl);
const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true;
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
const adminUrl = (adminPushRelayUrl ?? '').trim();
const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL);
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
// Pull the admin-configured URL into local state when policy loads/changes.
// When locked, the admin value always wins; when only set (not locked), use
// it as the initial default but let the user override.
useEffect(() => {
if (pushRelayLocked && adminUrl) {
setRelayUrl(adminUrl);
} else if (adminUrl) {
setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current));
}
}, [adminUrl, pushRelayLocked]);
useEffect(() => {
if (!supported) return;
if (!client) return;
@@ -139,17 +125,12 @@ export function NotificationSettings() {
<SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium inline-flex items-center gap-1.5" htmlFor="push-relay-url">
<label className="text-sm font-medium" htmlFor="push-relay-url">
{t('push.relay_label')}
{pushRelayLocked && (
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
)}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">
{pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
</p>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<input
id="push-relay-url"
type="url"
@@ -159,8 +140,7 @@ export function NotificationSettings() {
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked}
readOnly={pushRelayLocked}
disabled={busy || pushStatus.kind === 'unsupported'}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
+8
View File
@@ -25,6 +25,7 @@ export function ReadingSettings() {
showPreview,
mailLayout,
disableThreading,
plainTextMode,
emailsPerPage,
mailAttachmentAction,
attachmentPosition,
@@ -193,6 +194,13 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
+13 -45
View File
@@ -6,11 +6,11 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights, FileNodeRights } from "@/lib/jmap/types";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook" | "file";
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights;
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
@@ -39,21 +39,6 @@ const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, A
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
@@ -73,29 +58,6 @@ function detectAddressBookPreset(r: AddressBookRights): RolePreset {
return "custom";
}
function detectFilePreset(r: FileNodeRights): RolePreset {
for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights][]) {
const keys = Object.keys(preset) as (keyof FileNodeRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined {
if (preset === "custom") return undefined;
if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS];
if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS];
return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
}
function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights);
if (kind === "file") return detectFilePreset(rights as FileNodeRights);
return detectAddressBookPreset(rights as AddressBookRights);
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
@@ -164,7 +126,9 @@ export function ShareCollectionDialog({
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = presetRights(kind, preset);
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
if (!rights) return;
setSavingId(principalId);
try {
@@ -190,8 +154,10 @@ export function ShareCollectionDialog({
};
const handleAdd = async (principal: Principal) => {
const rights = presetRights(kind, "read");
if (!rights) return;
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
@@ -260,7 +226,9 @@ export function ShareCollectionDialog({
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = detectPreset(kind, rights);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<Avatar
+6 -169
View File
@@ -1,10 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection } from './settings-section';
import { cn } from '@/lib/utils';
import { Check, Lock } from 'lucide-react';
import { Check, Palette, Lock } from 'lucide-react';
import { toast } from '@/stores/toast-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -14,18 +14,6 @@ export function ThemesSettings() {
const themePolicy = getThemePolicy();
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
// Render each preview in the variant matching the app's current mode, and
// keep it in sync when the user toggles light/dark elsewhere.
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const root = document.documentElement;
const update = () => setIsDark(root.classList.contains('dark'));
update();
const obs = new MutationObserver(update);
obs.observe(root, { attributes: true, attributeFilter: ['class'] });
return () => obs.disconnect();
}, []);
// Filter out themes disabled by admin policy
const visibleThemes = installedThemes.filter(
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
@@ -48,9 +36,6 @@ export function ThemesSettings() {
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
const handleActivate = (id: string | null) => {
// Clicking the already-active theme is a no-op (no re-apply, no toast).
if (id === activeThemeId) return;
if (forcedThemeId && id !== forcedThemeId) {
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`);
@@ -76,9 +61,6 @@ export function ThemesSettings() {
<ThemeCard
name="Default"
author="Bulwark"
isDefaultTheme
variants={['light', 'dark']}
isDark={isDark}
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
@@ -95,8 +77,6 @@ export function ThemesSettings() {
name={theme.name}
author={theme.author}
preview={theme.preview}
css={theme.css}
isDark={isDark}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id}
@@ -118,9 +98,6 @@ interface ThemeCardProps {
name: string;
author: string;
preview?: string;
css?: string;
isDark?: boolean;
isDefaultTheme?: boolean;
isActive: boolean;
isBuiltIn: boolean;
isDefault?: boolean;
@@ -130,8 +107,7 @@ interface ThemeCardProps {
onActivate: () => void;
}
function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
const colors = resolveThemeColors({ css, variants, isDark: !!isDark, isDefaultTheme: !!isDefaultTheme });
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
return (
<div data-search-label={name} className="relative">
<button
@@ -146,13 +122,12 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv
disabled && !isActive && 'hover:border-border'
)}
>
{/* Preview: marketplace image when shipped, otherwise a mini app
mockup painted from the theme's own colour tokens. */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted">
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<ThemePreview colors={colors} />
<Palette className="w-8 h-8 text-muted-foreground/40" />
)}
</div>
@@ -187,141 +162,3 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv
</div>
);
}
// ─── Theme Preview ───────────────────────────────────────────
interface ThemeColors {
background: string;
sidebar: string;
card: string;
primary: string;
primaryForeground: string;
foreground: string;
mutedForeground: string;
border: string;
}
// The built-in "Default" theme has no `css` of its own (it's the app's base
// tokens); these mirror app/globals.css so its card previews accurately.
const DEFAULT_LIGHT: ThemeColors = {
background: '#ffffff', sidebar: '#f8fafc', card: '#ffffff', primary: '#3b82f6',
primaryForeground: '#ffffff', foreground: '#0f172a', mutedForeground: '#64748b', border: '#e2e8f0',
};
const DEFAULT_DARK: ThemeColors = {
background: '#0a0a0a', sidebar: '#0a0a0a', card: '#141414', primary: '#fafafa',
primaryForeground: '#171717', foreground: '#fafafa', mutedForeground: '#a3a3a3', border: '#262626',
};
// Pull a handful of structural colour tokens out of a theme's compiled CSS.
// Themes declare light tokens under `:root { … }` and dark under `.dark { … }`;
// neither block nests braces, so a non-greedy capture is enough.
function extractThemeColors(css: string, dark: boolean): ThemeColors | null {
const block = css.match(dark ? /\.dark\s*\{([\s\S]*?)\}/ : /:root\s*\{([\s\S]*?)\}/);
if (!block) return null;
const body = block[1];
const get = (name: string): string | undefined => {
const m = body.match(new RegExp(`--color-${name}\\s*:\\s*([^;]+);`));
return m ? m[1].trim() : undefined;
};
const background = get('background');
if (!background) return null;
return {
background,
sidebar: get('sidebar') ?? background,
card: get('card') ?? background,
primary: get('primary') ?? '#888888',
primaryForeground: get('primary-foreground') ?? '#ffffff',
foreground: get('foreground') ?? '#000000',
mutedForeground: get('muted-foreground') ?? '#888888',
border: get('border') ?? 'rgba(128,128,128,0.3)',
};
}
function resolveThemeColors({ css, variants, isDark, isDefaultTheme }: {
css?: string;
variants?: ('light' | 'dark')[];
isDark: boolean;
isDefaultTheme: boolean;
}): ThemeColors {
// Prefer the variant matching the app's current mode; fall back to the one
// the theme actually ships if it's single-variant.
const hasLight = !variants || variants.includes('light');
const hasDark = !variants || variants.includes('dark');
const wantDark = (isDark && hasDark) || !hasLight;
if (isDefaultTheme || !css) return wantDark ? DEFAULT_DARK : DEFAULT_LIGHT;
return (
extractThemeColors(css, wantDark) ??
extractThemeColors(css, !wantDark) ??
(wantDark ? DEFAULT_DARK : DEFAULT_LIGHT)
);
}
// A miniature of the real three-pane mailbox - icon nav rail, folder sidebar,
// message list (first row selected) and reading pane - painted with the
// theme's own colours, standing in for a screenshot.
function ThemePreview({ colors }: { colors: ThemeColors }) {
const rail = `1px solid ${colors.border}`;
return (
<div className="w-full h-full flex overflow-hidden" style={{ backgroundColor: colors.background }}>
{/* Icon nav rail */}
<div
className="flex flex-col items-center gap-1 py-1.5 shrink-0"
style={{ width: '9%', backgroundColor: colors.sidebar, borderRight: rail }}
>
<div className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.primary }} />
{[0, 1, 2].map((i) => (
<div key={i} className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
))}
</div>
{/* Folder sidebar */}
<div
className="flex flex-col gap-1 p-1.5 shrink-0"
style={{ width: '25%', backgroundColor: colors.sidebar, borderRight: rail }}
>
<div className="flex items-center gap-1 mb-0.5">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
<div className="h-1 rounded-full flex-1" style={{ backgroundColor: colors.foreground, opacity: 0.55 }} />
</div>
{[0.8, 0.65, 0.72, 0.5].map((w, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-1 h-1 rounded-sm shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.45 }} />
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
</div>
))}
</div>
{/* Message list */}
<div className="flex flex-col shrink-0" style={{ width: '36%', backgroundColor: colors.background, borderRight: rail }}>
{/* Selected row */}
<div className="flex items-start gap-1 p-1" style={{ backgroundColor: colors.primary }}>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.primaryForeground, opacity: 0.85 }} />
<div className="flex-1 flex flex-col gap-0.5">
<div className="h-1 rounded-full" style={{ width: '70%', backgroundColor: colors.primaryForeground, opacity: 0.9 }} />
<div className="h-1 rounded-full" style={{ width: '90%', backgroundColor: colors.primaryForeground, opacity: 0.55 }} />
</div>
</div>
{/* Unread + read rows */}
{[0.6, 0.7, 0.55].map((w, i) => (
<div key={i} className="flex items-start gap-1 p-1" style={{ borderTop: rail }}>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
<div className="flex-1 flex flex-col gap-0.5">
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.foreground, opacity: i === 0 ? 0.7 : 0.5 }} />
<div className="h-1 rounded-full" style={{ width: '85%', backgroundColor: colors.mutedForeground, opacity: 0.35 }} />
</div>
</div>
))}
</div>
{/* Reading pane */}
<div className="flex-1 flex flex-col gap-1 p-1.5 min-w-0">
<div className="h-1.5 rounded-full" style={{ width: '70%', backgroundColor: colors.foreground, opacity: 0.85 }} />
<div className="h-1 rounded-full mb-1" style={{ width: '40%', backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
{[0.95, 0.85, 0.9, 0.6].map((w, i) => (
<div key={i} className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.3 }} />
))}
</div>
</div>
);
}
+3 -5
View File
@@ -6,7 +6,6 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -20,7 +19,6 @@ export function VacationSettings() {
const t = useTranslations('settings.vacation');
const tNotifications = useTranslations('notifications');
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const {
isEnabled,
fromDate,
@@ -45,9 +43,9 @@ export function VacationSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchVacationResponse(client, managedAccountId ?? undefined);
void fetchVacationResponse(client);
}
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
}, [client, isSupported, fetchVacationResponse]);
useEffect(() => {
setLocalEnabled(isEnabled);
@@ -103,7 +101,7 @@ export function VacationSettings() {
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
}, managedAccountId ?? undefined);
});
toast.success(tNotifications('vacation_saved'));
} catch (error) {
-9
View File
@@ -270,15 +270,6 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
alt=""
className="w-full h-full object-cover"
onError={handleImgError}
// /api/favicon returns a 1x1 transparent PNG (HTTP 200) when no real
// favicon exists, to avoid spamming the DevTools console with 404s.
// Detect that sentinel by naturalWidth and fall back to initials.
onLoad={(e) => {
const img = e.currentTarget;
if (isFavicon && img.naturalWidth <= 1) {
handleImgError();
}
}}
/>
) : (
getInitials()
+1 -4
View File
@@ -32,9 +32,6 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
// Measure the rendered menu and clamp it inside the viewport before the
// browser paints. We hide the element until this runs so the user never
// sees the menu jump from an unclamped position to a clamped one.
// `mounted` must be a dependency: on the very first open the menu isn't
// in the DOM yet (mounted is still false), so this effect has to re-run
// after the mount flip or the menu stays visibility:hidden.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
@@ -60,7 +57,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition({ x, y });
}, [isOpen, mounted, position.x, position.y]);
}, [isOpen, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
-12
View File
@@ -98,17 +98,6 @@ export function FlagDE(props: FlagProps) {
);
}
/** Hungary Red, White, Green horizontal */
export function FlagHU(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 3" width={W} height={H} className={flagClass} {...props}>
<rect width="6" height="1" fill="#CD2A3E" />
<rect y="1" width="6" height="1" fill="#fff" />
<rect y="2" width="6" height="1" fill="#436F4D" />
</svg>
);
}
/** Latvia Maroon, White, Maroon horizontal */
export function FlagLV(props: FlagProps) {
return (
@@ -230,7 +219,6 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
en: FlagGB,
es: FlagES,
fr: FlagFR,
hu: FlagHU,
it: FlagIT,
ja: FlagJP,
ko: FlagKR,
-1
View File
@@ -15,7 +15,6 @@ const languages = [
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'hu', label: 'Magyar' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
+1 -5
View File
@@ -173,11 +173,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// import call to target the owner's JMAP account.
const jmapDestId = mailbox.originalId || mailbox.id;
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
// Mirror image for the source: a shared group mailbox is accessed
// through the viewing user's client, but the email/blob live in the
// owner's JMAP account, so the copy/delete must target it.
const sourceJmapOverride = sourceMb?.isShared ? sourceMb.accountId : undefined;
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride);
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id);
-3
View File
@@ -26,9 +26,6 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
case 'hu':
messages = (await import('../locales/hu/common.json')).default;
break;
case 'it':
messages = (await import('../locales/it/common.json')).default;
break;
+1 -1
View File
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
// Fallback locale used when the visitor's Accept-Language header does not
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
-104
View File
@@ -1,104 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
// `browser-navigation` reads NEXT_PUBLIC_BASE_PATH into a module-load constant
// (mirroring how next.config.ts bakes basePath in at build time), so each case
// has to re-import the module under a fresh env. These tests pin down the
// promise made to subpath deployments: the built-in branding *defaults*
// (FAVICON_URL, LOGIN_LOGO_*) — not just admin-set custom values — get the
// mount prefix, because every consumer wraps them in withBasePath(). See #330.
async function loadNav(basePath?: string) {
vi.resetModules();
if (basePath === undefined) {
delete process.env.NEXT_PUBLIC_BASE_PATH;
} else {
process.env.NEXT_PUBLIC_BASE_PATH = basePath;
}
return import('@/lib/browser-navigation');
}
// The fallback values from CONFIG_ENV_MAP that ship in the box.
const DEFAULT_FAVICON = '/branding/Bulwark_Favicon.svg';
const DEFAULT_LOGIN_LOGO_LIGHT = '/branding/Bulwark_Logo_Color.svg';
const DEFAULT_LOGIN_LOGO_DARK = '/branding/Bulwark_Logo_White.svg';
afterEach(() => {
delete process.env.NEXT_PUBLIC_BASE_PATH;
vi.resetModules();
});
describe('withBasePath — asset-URL fallbacks under a subpath', () => {
it('leaves branding defaults untouched when no base path is set', async () => {
const { withBasePath } = await loadNav(undefined);
// jsdom's window.location.pathname is "/", so runtime detection finds no
// prefix either — the default paths pass through unchanged.
expect(withBasePath(DEFAULT_FAVICON)).toBe(DEFAULT_FAVICON);
expect(withBasePath(DEFAULT_LOGIN_LOGO_LIGHT)).toBe(DEFAULT_LOGIN_LOGO_LIGHT);
expect(withBasePath(DEFAULT_LOGIN_LOGO_DARK)).toBe(DEFAULT_LOGIN_LOGO_DARK);
});
it('prefixes the built-in favicon and login-logo defaults', async () => {
const { withBasePath } = await loadNav('/webmail');
expect(withBasePath(DEFAULT_FAVICON)).toBe('/webmail/branding/Bulwark_Favicon.svg');
expect(withBasePath(DEFAULT_LOGIN_LOGO_LIGHT)).toBe('/webmail/branding/Bulwark_Logo_Color.svg');
expect(withBasePath(DEFAULT_LOGIN_LOGO_DARK)).toBe('/webmail/branding/Bulwark_Logo_White.svg');
});
it('is idempotent for an already-prefixed value (no double prefix)', async () => {
const { withBasePath } = await loadNav('/webmail');
expect(withBasePath('/webmail/branding/Bulwark_Favicon.svg')).toBe('/webmail/branding/Bulwark_Favicon.svg');
expect(withBasePath('/webmail')).toBe('/webmail');
});
it('passes through external, protocol-relative, data, and empty values', async () => {
const { withBasePath } = await loadNav('/webmail');
expect(withBasePath('https://cdn.example.com/logo.svg')).toBe('https://cdn.example.com/logo.svg');
expect(withBasePath('//cdn.example.com/logo.svg')).toBe('//cdn.example.com/logo.svg');
expect(withBasePath('data:image/svg+xml,<svg/>')).toBe('data:image/svg+xml,<svg/>');
expect(withBasePath('')).toBe('');
expect(withBasePath(null)).toBe('');
expect(withBasePath(undefined)).toBe('');
});
it('strips a trailing slash on the configured base path', async () => {
const { withBasePath, getPathPrefix } = await loadNav('/webmail/');
expect(getPathPrefix()).toBe('/webmail');
expect(withBasePath(DEFAULT_FAVICON)).toBe('/webmail/branding/Bulwark_Favicon.svg');
});
it('getPathPrefix reports the static base path on the server (no window needed)', async () => {
const { getPathPrefix } = await loadNav('/webmail');
// SSR consumers (layout.tsx favicon metadata, manifest.ts) rely on this
// resolving from the build-time constant rather than window.location.
expect(getPathPrefix()).toBe('/webmail');
});
});
describe('toRouterPath — router.push paths under a subpath', () => {
// With a build-time basePath, Next's router prepends the prefix itself, so
// browser-derived paths (redirect_after_login stores
// window.location.pathname) must be stripped or the redirect lands on
// /webmail/webmail/en. See #390.
it('strips the static base path from a stored browser path', async () => {
const { toRouterPath } = await loadNav('/webmail');
expect(toRouterPath('/webmail/en')).toBe('/en');
expect(toRouterPath('/webmail/en/calendar?view=day')).toBe('/en/calendar?view=day');
expect(toRouterPath('/webmail')).toBe('/');
expect(toRouterPath('/webmail?compose=1')).toBe('/?compose=1');
});
it('leaves already-stripped and unrelated paths alone', async () => {
const { toRouterPath } = await loadNav('/webmail');
expect(toRouterPath('/en')).toBe('/en');
expect(toRouterPath('/')).toBe('/');
// Shares the prefix text but is a different first segment.
expect(toRouterPath('/webmail2/en')).toBe('/webmail2/en');
});
it('passes paths through unchanged when no base path is built in', async () => {
// Legacy runtime-detected proxy mounts: Next knows nothing about the
// prefix, so router.push needs the full prefixed path.
const { toRouterPath } = await loadNav(undefined);
expect(toRouterPath('/webmail/en')).toBe('/webmail/en');
expect(toRouterPath('/en')).toBe('/en');
});
});
+2 -4
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
import { BUILTIN_THEMES } from '../builtin-themes';
describe('BUILTIN_THEMES', () => {
it('contains exactly 6 themes', () => {
expect(BUILTIN_THEMES).toHaveLength(6);
it('contains exactly 3 themes', () => {
expect(BUILTIN_THEMES).toHaveLength(3);
});
it('all themes have required fields', () => {
@@ -43,8 +43,6 @@ describe('BUILTIN_THEMES', () => {
expect(names).toContain('Nord');
expect(names).toContain('Catppuccin');
expect(names).toContain('Solarized');
expect(names).toContain('Roundcube Elastic');
expect(names).toContain('Aurora Glass');
});
it('theme IDs are unique', () => {
+2 -81
View File
@@ -36,29 +36,16 @@ describe('config API route', () => {
delete process.env.LOGIN_IMPRINT_URL;
delete process.env.LOGIN_PRIVACY_POLICY_URL;
delete process.env.LOGIN_WEBSITE_URL;
delete process.env.DOMAIN_BRANDING;
});
afterEach(() => {
process.env = { ...originalEnv };
});
function mockRequest(headers: Record<string, string> = {}): unknown {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
return {
headers: {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
},
};
}
async function getConfig(headers?: Record<string, string>) {
async function getConfig() {
// Re-import to pick up env changes
const { GET } = await import('@/app/api/config/route');
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
const response = await GET();
return response.json();
}
@@ -205,70 +192,4 @@ describe('config API route', () => {
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
});
describe('per-domain branding overrides', () => {
it('applies overrides for the matching host', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{
host: 'mail1.example.com',
loginCompanyName: 'Brand One',
loginWebsiteUrl: 'https://one.example',
},
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://one.example');
});
it('falls through to the global value when the host has no entry', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'unmapped.example.com' });
expect(config.loginCompanyName).toBe('Default Co');
});
it('falls through field-by-field when the matching entry omits a field', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://default.example');
});
it('prefers X-Forwarded-Host over Host', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'public.example.com', loginCompanyName: 'Public' },
]);
const config = await getConfig({
host: 'internal.example.com',
'x-forwarded-host': 'public.example.com',
});
expect(config.loginCompanyName).toBe('Public');
});
it('strips the port from the host header before matching', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com:8443' });
expect(config.loginCompanyName).toBe('Brand One');
});
});
});
-151
View File
@@ -1,151 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
function mockHeaders(map: Record<string, string>): Headers {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v;
return {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
} as unknown as Headers;
}
describe('parseDomainBranding', () => {
it('returns [] for null/undefined/empty', () => {
expect(parseDomainBranding(null)).toEqual([]);
expect(parseDomainBranding(undefined)).toEqual([]);
expect(parseDomainBranding('')).toEqual([]);
expect(parseDomainBranding([])).toEqual([]);
});
it('parses a stringified JSON array', () => {
const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]);
expect(parseDomainBranding(raw)).toEqual([
{ host: 'mail.example.com', loginCompanyName: 'Acme' },
]);
});
it('lower-cases hosts and strips trailing dots', () => {
expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([
{ host: 'mail.example.com' },
]);
});
it('accepts wildcard hosts', () => {
expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([
{ host: '*.example.com', loginCompanyName: 'Wild' },
]);
});
it('drops entries with invalid hosts', () => {
const out = parseDomainBranding([
{ host: '' },
{ host: 'has space.com' },
{ host: 'foo..bar' },
{ host: '*.*.example.com' }, // embedded wildcard not allowed
{ host: 'good.example.com' },
]);
expect(out.map(e => e.host)).toEqual(['good.example.com']);
});
it('drops duplicate hosts, keeping the first', () => {
const out = parseDomainBranding([
{ host: 'foo.com', loginCompanyName: 'First' },
{ host: 'FOO.com', loginCompanyName: 'Second' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]);
});
it('ignores non-string and empty-string override fields', () => {
const out = parseDomainBranding([
{
host: 'foo.com',
loginCompanyName: '',
loginImprintUrl: 42,
loginWebsiteUrl: 'https://foo.com',
},
]);
expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]);
});
it('ignores unknown fields', () => {
const out = parseDomainBranding([
{ host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]);
});
});
describe('pickRequestHost', () => {
it('returns null when no host headers are set', () => {
expect(pickRequestHost(mockHeaders({}))).toBeNull();
});
it('prefers X-Forwarded-Host over Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'forwarded.example.com',
host: 'origin.example.com',
}))).toBe('forwarded.example.com');
});
it('falls back to Host when X-Forwarded-Host is absent', () => {
expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com');
});
it('strips the port', () => {
expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com');
});
it('takes the first entry of a comma-separated X-Forwarded-Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'first.example.com, second.example.com',
}))).toBe('first.example.com');
});
it('lower-cases the result', () => {
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
});
});
describe('matchDomainBranding', () => {
const entries: DomainBrandingEntry[] = [
{ host: 'mail.example.com', loginCompanyName: 'Exact' },
{ host: '*.example.com', loginCompanyName: 'Wildcard' },
{ host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' },
{ host: 'other.com', loginCompanyName: 'Other' },
];
it('returns {} when host is null', () => {
expect(matchDomainBranding(null, entries)).toEqual({});
});
it('returns {} when no entries match', () => {
expect(matchDomainBranding('unknown.org', entries)).toEqual({});
});
it('prefers exact match over wildcard', () => {
expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact');
});
it('matches wildcards on subdomains', () => {
expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard');
});
it('prefers the longest wildcard suffix', () => {
expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard');
});
it('does not match the wildcard host against the apex domain', () => {
expect(matchDomainBranding('example.com', entries)).toEqual({});
});
it('is case-insensitive on the request host', () => {
expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact');
});
});
@@ -4,11 +4,6 @@ import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
INLINE_IMAGE_PLACEHOLDER,
splitRecipients,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
@@ -117,84 +112,3 @@ describe("replaceInlineImagePlaceholders", () => {
expect(out).toBe(html);
});
});
describe("splitRecipients", () => {
it("splits a plain comma-separated list", () => {
expect(splitRecipients("alice@x.com, bob@x.com")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("trims whitespace and drops empty segments", () => {
expect(splitRecipients(" alice@x.com ,, bob@x.com ,")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("keeps a quoted display name containing a comma intact", () => {
expect(splitRecipients('"Doo, John" <john@doo.org>, alice@x.com')).toEqual([
'"Doo, John" <john@doo.org>',
"alice@x.com",
]);
});
it("does not split on a comma inside angle brackets", () => {
expect(splitRecipients("Group <a,b@x.com>, c@x.com")).toEqual([
"Group <a,b@x.com>",
"c@x.com",
]);
});
it("returns an empty array for an empty string", () => {
expect(splitRecipients("")).toEqual([]);
});
});
describe("formatRecipient / parseRecipient", () => {
it("returns a bare email when there is no name", () => {
expect(formatRecipient(undefined, "a@x.com")).toBe("a@x.com");
});
it("returns a bare email when the name equals the email", () => {
expect(formatRecipient("a@x.com", "a@x.com")).toBe("a@x.com");
});
it("formats a simple name without quoting", () => {
expect(formatRecipient("Alice", "a@x.com")).toBe("Alice <a@x.com>");
});
it("quotes a name containing a comma", () => {
expect(formatRecipient("Doo, John", "john@doo.org")).toBe(
'"Doo, John" <john@doo.org>'
);
});
it("parses a bare email", () => {
expect(parseRecipient("a@x.com")).toEqual({ email: "a@x.com" });
});
it("parses and unquotes a quoted comma name", () => {
expect(parseRecipient('"Doo, John" <john@doo.org>')).toEqual({
name: "Doo, John",
email: "john@doo.org",
});
});
});
describe("parseRecipientList / formatRecipientList", () => {
it("round-trips a comma-name recipient through serialize + parse", () => {
const list = [
{ name: "Doo, John", email: "john@doo.org" },
{ email: "alice@x.com" },
];
const serialized = formatRecipientList(list);
expect(serialized).toBe('"Doo, John" <john@doo.org>, alice@x.com');
expect(parseRecipientList(serialized)).toEqual(list);
});
it("parses an empty string to an empty array", () => {
expect(parseRecipientList("")).toEqual([]);
});
});
-61
View File
@@ -7,7 +7,6 @@ import {
getSecurityStatus,
parseSpamLLM,
extractListHeaders,
isAuthenticationSpoofed,
} from '../email-headers';
describe('parseAuthenticationResults', () => {
@@ -53,66 +52,6 @@ describe('parseAuthenticationResults', () => {
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
expect(result.spf?.result).toBe('softfail');
});
it('surfaces the most severe result when multiple SPF identities exist', () => {
// HELO temperror, MAIL FROM fail — the harder fail must be the headline.
const header =
'mx.example.com; spf=temperror (mx: dns timeout) smtp.helo=mail.spoof.com; spf=fail (mx: not authorized) smtp.mailfrom=victim.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('fail');
expect(result.spf?.domain).toBe('victim.com');
});
it('exposes all SPF results when more than one identity is evaluated', () => {
const header =
'spf=temperror smtp.helo=mail.spoof.com; spf=fail smtp.mailfrom=victim.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.all).toEqual([
{ result: 'temperror', identity: 'helo', domain: 'mail.spoof.com' },
{ result: 'fail', identity: 'mailfrom', domain: 'victim.com' },
]);
});
it('does not set `all` for a single SPF result', () => {
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
expect(result.spf?.all).toBeUndefined();
});
it('prefers the MAIL FROM identity when severities tie', () => {
const header = 'spf=pass smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.domain).toBe('example.com');
});
});
describe('isAuthenticationSpoofed', () => {
it('returns false when no auth results are present', () => {
expect(isAuthenticationSpoofed(undefined)).toBe(false);
expect(isAuthenticationSpoofed({})).toBe(false);
});
it('flags a DMARC fail as spoofed', () => {
expect(isAuthenticationSpoofed({ dmarc: { result: 'fail' } })).toBe(true);
});
it('flags a hard SPF fail without a passing DKIM as spoofed', () => {
expect(isAuthenticationSpoofed({ spf: { result: 'fail' } })).toBe(true);
expect(
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'fail' } })
).toBe(true);
});
it('does not flag an SPF fail rescued by a passing DKIM', () => {
expect(
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'pass' } })
).toBe(false);
});
it('does not flag passing or ambiguous results', () => {
expect(isAuthenticationSpoofed({ spf: { result: 'pass' }, dmarc: { result: 'pass' } })).toBe(false);
expect(isAuthenticationSpoofed({ spf: { result: 'softfail' } })).toBe(false);
expect(isAuthenticationSpoofed({ spf: { result: 'temperror' } })).toBe(false);
});
});
describe('parseSpamScore', () => {

Some files were not shown because too many files have changed in this diff Show More