Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c7e991ff | ||
|
|
d1b2206aa0 | ||
|
|
0137a1a593 | ||
|
|
c4aa0c1772 | ||
|
|
9a302e5183 | ||
|
|
401870c9bb | ||
|
|
91cd087243 | ||
|
|
d04a8e578b | ||
|
|
e6d939ba43 | ||
|
|
5c1d59f38a | ||
|
|
159706a683 | ||
|
|
5928c6f73e | ||
|
|
7dccc2f432 | ||
|
|
dd322d4c9d | ||
|
|
7e15782fb3 | ||
|
|
1e8bd40b93 | ||
|
|
3dc1b4ceee | ||
|
|
1a3d359fee | ||
|
|
891c3250be | ||
|
|
4a3c775b40 | ||
|
|
f45b67fe19 | ||
|
|
c458091698 | ||
|
|
37cd5ca635 | ||
|
|
154ae84247 | ||
|
|
16f719066f | ||
|
|
17577e222c | ||
|
|
48821338c3 |
@@ -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"}]
|
||||
|
||||
@@ -225,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
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,96 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.3 (2026-06-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
|
||||
- **Mail**: Preview composer attachments inline (click to open)
|
||||
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
|
||||
- **Mail**: Read receipts (MDN, RFC 8098)
|
||||
- **Mail**: Editable, layout-preserving quote island when replying
|
||||
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
|
||||
- **Calendar**: Per-viewer colors for shared calendars (#345)
|
||||
- **Filters**: Extended filter rules — attachment field and multi-value conditions
|
||||
- **Settings**: New built-in themes — Aurora Glass and Elastic
|
||||
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
|
||||
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
|
||||
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
|
||||
- **Admin**: Toggle for search-engine indexing (robots)
|
||||
- **Admin**: `passwordHashFile` in `admin.json`
|
||||
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
|
||||
- **PWA**: Configurable install screenshots (per-domain)
|
||||
- **i18n**: Hungarian locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
|
||||
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
|
||||
- **Mail**: Empty Trash for shared and group folders (#387)
|
||||
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
|
||||
- **Mail**: Preserve the HTML signature when sending a quick reply
|
||||
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
|
||||
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
|
||||
- **Mail**: No more 404 console spam for missing sender favicons
|
||||
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
|
||||
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
|
||||
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
|
||||
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
|
||||
- **Themes**: Plugin slot iframes inherit host font and color tokens
|
||||
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
|
||||
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
|
||||
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
|
||||
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
|
||||
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
|
||||
- **UI**: Close pane gaps by centering the resize handle on the seam
|
||||
- **Settings**: Fix section gears permanently hijacking the active tab
|
||||
|
||||
## 1.7.2 (2026-05-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Scheduled send and send delay (#322)
|
||||
- **Mail**: Drag emails out to the file explorer as `.eml`
|
||||
- **Mail**: Import emails from `.zip` archives
|
||||
- **Mail**: "Move to Trash and mark as read" delete action (#323)
|
||||
- **Mail**: Include group inboxes in the unified mailbox view (#328)
|
||||
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
|
||||
- **Mail**: Allow drag-and-drop into shared mailboxes
|
||||
- **Composer**: Ctrl/Cmd+Enter sends the open draft
|
||||
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
|
||||
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
|
||||
- **Settings**: Post-export action (keep / archive / trash)
|
||||
- **Settings**: Template for multi-email `.zip` filenames
|
||||
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
|
||||
- **Admin**: Policy-controlled push relay URL with optional user lock
|
||||
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
|
||||
- **Mail**: Report real upload progress with XHR progress events (#333)
|
||||
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
|
||||
- **Mail**: Match user-avatar treatment on quick reply
|
||||
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
|
||||
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
|
||||
- **Composer**: Stop closing the form when editing any field
|
||||
- **Pro**: Keep the empty viewer pane visible in the split layout
|
||||
- **Pro**: Prevent an empty main pane when reordering tabs across panes
|
||||
- **Mobile**: Collapse focus mail layout to multi-line
|
||||
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
|
||||
- **Calendar**: Align continued multi-week events with the week's left edge
|
||||
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
|
||||
- **Calendar**: Convert `recurrenceRules` to singular in batch create
|
||||
- **Calendar**: Handle malformed event dates (#316)
|
||||
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
|
||||
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
|
||||
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
|
||||
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
+8
-14
@@ -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,7 +60,7 @@
|
||||
|
||||
## 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
|
||||
@@ -75,7 +72,7 @@
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators – 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
|
||||
@@ -88,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
|
||||
@@ -102,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`.
|
||||
|
||||
@@ -122,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
|
||||
@@ -132,7 +126,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, 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-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
@@ -86,7 +86,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
@@ -48,11 +48,9 @@ export default function FilesPage() {
|
||||
supportsFiles,
|
||||
selectedResources,
|
||||
uploadProgress,
|
||||
migrationProgress,
|
||||
clipboard,
|
||||
initClient,
|
||||
checkSupport,
|
||||
migrateLegacyFlatNodes,
|
||||
navigate,
|
||||
navigateByPath,
|
||||
refresh,
|
||||
@@ -162,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
|
||||
@@ -578,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useConfig } from "@/hooks/use-config";
|
||||
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";
|
||||
@@ -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");
|
||||
|
||||
@@ -55,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";
|
||||
@@ -72,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";
|
||||
|
||||
@@ -82,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);
|
||||
@@ -301,7 +299,6 @@ export default function Home() {
|
||||
fetchTagCounts,
|
||||
fetchEmailContent,
|
||||
isUnifiedView,
|
||||
unifiedRole,
|
||||
scheduledEmails,
|
||||
scheduledTotal,
|
||||
scheduledHasMore,
|
||||
@@ -611,7 +608,6 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onCompose: () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -754,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;
|
||||
}
|
||||
@@ -1132,7 +1128,6 @@ export default function Home() {
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -1140,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);
|
||||
@@ -1216,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) {
|
||||
@@ -1252,7 +1231,6 @@ export default function Home() {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
@@ -1362,7 +1340,6 @@ export default function Home() {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -1380,7 +1357,6 @@ export default function Home() {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -1389,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;
|
||||
@@ -1422,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');
|
||||
@@ -1448,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;
|
||||
@@ -1488,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);
|
||||
}
|
||||
@@ -1513,7 +1460,7 @@ export default function Home() {
|
||||
setConversationEmails([]);
|
||||
}
|
||||
|
||||
void refreshMailboxes();
|
||||
void fetchMailboxes(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
}
|
||||
@@ -2130,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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;
|
||||
@@ -2168,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,
|
||||
@@ -2176,7 +2111,7 @@ export default function Home() {
|
||||
headerFromEmail,
|
||||
undefined,
|
||||
headerFromName,
|
||||
finalHtmlBody,
|
||||
undefined,
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
@@ -2343,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');
|
||||
@@ -2352,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');
|
||||
@@ -2361,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');
|
||||
@@ -2466,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
|
||||
@@ -2489,7 +2421,6 @@ export default function Home() {
|
||||
onImportEmail={handleImportEmailFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
@@ -2550,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"
|
||||
@@ -2874,7 +2805,6 @@ export default function Home() {
|
||||
{/* Floating Compose Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -3093,7 +3023,6 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onCompose={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
SwatchBook,
|
||||
Download,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -146,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,
|
||||
};
|
||||
@@ -336,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) {
|
||||
@@ -369,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);
|
||||
@@ -596,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' },
|
||||
@@ -622,6 +607,7 @@ 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 }] : []),
|
||||
];
|
||||
@@ -947,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -74,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] || {};
|
||||
@@ -195,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">
|
||||
|
||||
@@ -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
-27
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+36
-66
@@ -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', ''),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, 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,
|
||||
@@ -97,7 +94,6 @@ export function CalendarSidebarPanel({
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||
@@ -307,11 +303,9 @@ export function CalendarSidebarPanel({
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canChangeColor = !!onColorChange;
|
||||
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
const canResetColor = !!onResetColor && hasColorOverride;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
@@ -341,13 +335,6 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{canResetColor && (
|
||||
<ContextMenuItem
|
||||
icon={Shuffle}
|
||||
label={tMgmt('random_color')}
|
||||
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,11 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -26,7 +23,6 @@ import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builde
|
||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
@@ -93,7 +89,6 @@ interface EmailComposerProps {
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
@@ -201,7 +196,6 @@ export function EmailComposer({
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
@@ -211,7 +205,6 @@ export function EmailComposer({
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
// Pro shell: surface identities from every connected account, grouped
|
||||
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
||||
@@ -235,16 +228,10 @@ export function EmailComposer({
|
||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||
? initialCurrentIdentityForSig
|
||||
: primaryIdentity;
|
||||
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote' &&
|
||||
hasInitialSignature;
|
||||
// New-mail composes always embed the signature into the editor body so it's
|
||||
// editable/removable (the previous read-only preview below the editor was
|
||||
// never spec-correct - see #329). Compose mode also ignores any leftover
|
||||
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
||||
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -272,9 +259,11 @@ export function EmailComposer({
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
return buildForwardSubject(replyTo.subject, t('prefix.forward'));
|
||||
const fwdPrefix = t('prefix.forward');
|
||||
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return buildReplySubject(replyTo.subject, t('prefix.reply'));
|
||||
const rePrefix = t('prefix.reply');
|
||||
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -283,26 +272,11 @@ export function EmailComposer({
|
||||
if (plainTextMode) {
|
||||
// Plain text mode: produce plain text body with no HTML
|
||||
const prefix = initialDraftText || "";
|
||||
// Compose mode: ignore any leftover replyTo (e.g. a selected mail in the
|
||||
// viewer) and embed the signature directly into the body so it's
|
||||
// editable. Fixes #329 (A,B).
|
||||
if (mode === 'compose') {
|
||||
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`;
|
||||
}
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward "From:" shows the full sender incl. address; reply line keeps
|
||||
// the bare name (reads naturally in the localized "On … wrote:" line).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||
@@ -323,36 +297,19 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||
// Compose mode: ignore any leftover replyTo and embed the signature
|
||||
// directly into the body so the user can edit/delete it. The leading
|
||||
// empty paragraph gives the cursor a place to land above the signature.
|
||||
if (mode === 'compose') {
|
||||
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||
const composePrefix = prefix || '<p></p>';
|
||||
const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
return `${composePrefix}${embedded}`;
|
||||
}
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
@@ -361,46 +318,35 @@ export function EmailComposer({
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
if (replyTo.htmlBody) {
|
||||
// Layout-heavy original: embed verbatim as a QuotedHtml island so
|
||||
// nested tables / MJML survive 1:1 (sanitize strips scripts/styles
|
||||
// first; cid rewrite runs after so its data-cid markers survive).
|
||||
const island = buildQuotedHtmlBlock(
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
|
||||
}
|
||||
const escaped = replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '';
|
||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||
const originalHtml = replyTo.htmlBody
|
||||
? rewriteCidImagesForEditor(replyTo.htmlBody)
|
||||
: (replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '');
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
|
||||
: escaped;
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
||||
: originalHtml;
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||
// its layout survives the editor round-trip. Sanitize first to strip
|
||||
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||
// aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false.
|
||||
const island = buildQuotedHtmlBlock(
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div>${island}`;
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
// cid: image refs are rewritten so they render in the editor (browsers
|
||||
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
|
||||
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -413,7 +359,6 @@ export function EmailComposer({
|
||||
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
|
||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||
@@ -454,7 +399,6 @@ export function EmailComposer({
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||
const [smimeSign_, setSmimeSign] = useState(false);
|
||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
@@ -527,15 +471,10 @@ export function EmailComposer({
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
// Replies/forwards only embed when configured for "above quote". Compose
|
||||
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
|
||||
if (!isReplyLike && mode !== 'compose') return;
|
||||
if (isReplyLike && signaturePosition !== 'above_quote') return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
|
||||
// serializeEditorContent (not getHTML) so a QuotedHtml island's verbatim
|
||||
// body isn't lost during the signature splice + setContent round-trip.
|
||||
const currentHtml = serializeEditorContent(editor);
|
||||
const currentHtml = editor.getHTML();
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||
if (!startEl) return;
|
||||
@@ -557,23 +496,15 @@ export function EmailComposer({
|
||||
if (!parent) return;
|
||||
|
||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
||||
// from startEl to the next quote boundary if no end marker is present.
|
||||
// The quote boundary is either a legacy <blockquote> or the QuotedHtml
|
||||
// island wrapper (<div data-quoted-html>) - stop before either so the
|
||||
// signature splice never eats into the quoted body.
|
||||
// from startEl to the next blockquote if no end marker is present.
|
||||
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
|
||||
const isQuoteBoundary = (n: Node | null): boolean => {
|
||||
if (!n || n.nodeType !== 1) return false;
|
||||
const el = n as Element;
|
||||
return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html');
|
||||
};
|
||||
const toRemove: Node[] = [];
|
||||
let cursor: Node | null = startEl;
|
||||
while (cursor) {
|
||||
toRemove.push(cursor);
|
||||
if (cursor === removeUntil) break;
|
||||
const next: Node | null = cursor.nextSibling;
|
||||
if (!removeUntil && isQuoteBoundary(next)) break;
|
||||
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
|
||||
cursor = next;
|
||||
}
|
||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||
@@ -772,16 +703,9 @@ export function EmailComposer({
|
||||
// Ref to latest saveDraft for use in event handlers with stale closures
|
||||
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Set by the explicit close paths (clean close, save-and-close, discard) so
|
||||
// the unmount auto-save below doesn't fire and stash a stale pendingDraft
|
||||
// on the parent (#329 D). Without this, "Reply → Discard → New mail" would
|
||||
// open the next composer with the discarded reply's mode/replyTo.
|
||||
const explicitCloseRef = useRef(false);
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
if (onSaveState && isDirtyRef.current) {
|
||||
const s = stateRef.current;
|
||||
onSaveState({
|
||||
@@ -1126,43 +1050,6 @@ export function EmailComposer({
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Inline preview for composer attachments, reusing the message viewer's
|
||||
// FilePreviewModal (so previewability and the open-in-new-tab safety gate are
|
||||
// handled there). Prefer the local File - no network - and fall back to the
|
||||
// uploaded blob (forwarded attachments, which carry only a blobId).
|
||||
const getPreviewAttachmentContent = useCallback(async () => {
|
||||
if (!previewAttachment) throw new Error('No attachment selected');
|
||||
if (previewAttachment.file) {
|
||||
return {
|
||||
blob: previewAttachment.file,
|
||||
contentType: previewAttachment.type || previewAttachment.file.type || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
if (composerClient && previewAttachment.blobId) {
|
||||
const blob = await composerClient.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream' };
|
||||
}
|
||||
throw new Error('No attachment content available');
|
||||
}, [previewAttachment, composerClient]);
|
||||
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
if (!previewAttachment) return;
|
||||
if (previewAttachment.file) {
|
||||
const url = URL.createObjectURL(previewAttachment.file);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = previewAttachment.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (composerClient && previewAttachment.blobId) {
|
||||
await composerClient.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
}
|
||||
}, [previewAttachment, composerClient]);
|
||||
|
||||
// Auto-save draft functionality
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
@@ -1485,14 +1372,12 @@ export function EmailComposer({
|
||||
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||
|
||||
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||
// The signature is embedded into the body during init for compose mode
|
||||
// (when the initial identity had a signature) and for above-quote
|
||||
// replies/forwards - skip the trailing append in those cases so we don't
|
||||
// duplicate it.
|
||||
// When "above quote" mode is configured for replies/forwards, the signature
|
||||
// was embedded into the body during init (see getInitialBody) so the
|
||||
// trailing append must be skipped to avoid duplicating it.
|
||||
const signatureAlreadyInBody =
|
||||
shouldEmbedSignatureInNewMail ||
|
||||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote');
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote';
|
||||
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
@@ -1734,7 +1619,6 @@ export function EmailComposer({
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
requestReadReceipt,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
});
|
||||
|
||||
@@ -1809,7 +1693,6 @@ export function EmailComposer({
|
||||
}, []);
|
||||
|
||||
const cleanClose = () => {
|
||||
explicitCloseRef.current = true;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
@@ -1818,7 +1701,6 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleSaveDraftAndClose = async () => {
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1829,7 +1711,6 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleDiscardAndClose = () => {
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -2226,9 +2107,8 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Hide the visual signature preview when the signature has already been
|
||||
embedded into the body (compose, or above-quote replies). */}
|
||||
{(shouldEmbedSignatureInNewMail
|
||||
|| ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote')) ? null
|
||||
embedded into the body above the quote (otherwise it would appear twice). */}
|
||||
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
|
||||
: plainTextMode ? (
|
||||
getPlainTextSignature(signatureIdentity) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
@@ -2247,21 +2127,7 @@ export function EmailComposer({
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4 py-2 border-t shrink-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => {
|
||||
// Clickable to preview only once it has content (local File or an
|
||||
// uploaded blob) and the type is previewable; never mid-upload.
|
||||
const canPreview = !att.uploading && !att.error
|
||||
&& (!!att.file || !!att.blobId)
|
||||
&& isFilePreviewable(att.name, att.type);
|
||||
const label = (
|
||||
<>
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
({formatFileSize(att.size)})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
@@ -2283,18 +2149,10 @@ export function EmailComposer({
|
||||
) : (
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewAttachment(att)}
|
||||
title={att.name}
|
||||
className="flex items-center gap-2 min-w-0 hover:underline"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">{label}</div>
|
||||
)}
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
({formatFileSize(att.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||
@@ -2304,8 +2162,7 @@ export function EmailComposer({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{attachments.length > 3 && (
|
||||
<button
|
||||
onClick={() => setShowAllAttachments(prev => !prev)}
|
||||
@@ -2319,7 +2176,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
@@ -2382,21 +2239,6 @@ export function EmailComposer({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRequestReadReceipt(v => !v)}
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
requestReadReceipt && "bg-green-600 text-white hover:bg-green-600 hover:text-white dark:bg-green-600 dark:hover:bg-green-600"
|
||||
)}
|
||||
title={requestReadReceipt ? t('read_receipt_on') : t('read_receipt_off')}
|
||||
aria-pressed={requestReadReceipt}
|
||||
>
|
||||
<MailCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
@@ -2635,15 +2477,6 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewAttachment && (
|
||||
<FilePreviewModal
|
||||
name={previewAttachment.name}
|
||||
onClose={() => setPreviewAttachment(null)}
|
||||
onDownload={handlePreviewAttachmentDownload}
|
||||
getFileContent={getPreviewAttachmentContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -913,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(), []);
|
||||
@@ -2196,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,
|
||||
@@ -2899,10 +2893,7 @@ 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. */
|
||||
html, body { overflow: hidden; height: auto !important; }
|
||||
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; }
|
||||
@@ -3261,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 (
|
||||
@@ -4226,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>
|
||||
@@ -4817,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')} />
|
||||
)}
|
||||
@@ -5070,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">
|
||||
@@ -5128,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">
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Renders a .eml attachment like an email: header (from/to/subject/date) + the
|
||||
// body, plus the message's own attachments. The body is sanitized with
|
||||
// DOMPurify AND rendered in a fully-locked sandbox iframe (sandbox="" - no
|
||||
// scripts, no same-origin), so a script-bearing .eml can never execute in our
|
||||
// origin. Parsing happens in the caller (FilePreviewModal); this is pure
|
||||
// presentation.
|
||||
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
|
||||
const bodyDoc = message.html
|
||||
? sanitizeEmailHtmlForIframe(message.html)
|
||||
: message.text
|
||||
? `<pre style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
|
||||
: "";
|
||||
|
||||
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[number]) => {
|
||||
if (!att.content) return;
|
||||
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
|
||||
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
|
||||
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = att.filename || "attachment";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-full self-start overflow-auto rounded-lg border border-border bg-background shadow-2xl p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{message.from && (
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||
)}
|
||||
{message.to && message.to.length > 0 && (
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
{bodyDoc && (
|
||||
<iframe
|
||||
title={message.subject || "email"}
|
||||
sandbox=""
|
||||
srcDoc={bodyDoc}
|
||||
className="w-full min-h-[55vh] mt-3 rounded bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => downloadAttachment(att)}
|
||||
title={t("download")}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
|
||||
>
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="max-w-[200px] truncate">{att.filename || "attachment"}</span>
|
||||
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -904,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');
|
||||
};
|
||||
|
||||
@@ -942,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`} />;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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't remind me again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,25 @@ import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
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,
|
||||
@@ -164,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
|
||||
@@ -313,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'));
|
||||
@@ -389,7 +396,7 @@ export function CalendarManagementSettings() {
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="space-y-2">
|
||||
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
||||
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
|
||||
const color = cal.color || '#3b82f6';
|
||||
|
||||
if (editingId === cal.id) {
|
||||
return (
|
||||
|
||||
@@ -28,8 +28,6 @@ export function ComposingSettings() {
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault,
|
||||
readReceiptResponse,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
@@ -80,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 })}
|
||||
|
||||
@@ -36,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");
|
||||
@@ -107,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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -1,74 +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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { snapshotHostTheme, themeSnapshotToCSS, type ThemeSnapshot } from '../plugin-sandbox/host-theme';
|
||||
|
||||
describe('host-theme', () => {
|
||||
describe('themeSnapshotToCSS', () => {
|
||||
it('emits the token values, font, and color-scheme', () => {
|
||||
const snapshot: ThemeSnapshot = {
|
||||
dark: false,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
vars: { '--color-background': '#ffffff', '--color-foreground': '#0f172a' },
|
||||
};
|
||||
const css = themeSnapshotToCSS(snapshot);
|
||||
expect(css).toContain('--color-background: #ffffff;');
|
||||
expect(css).toContain('--color-foreground: #0f172a;');
|
||||
expect(css).toContain('font-family: Inter, sans-serif;');
|
||||
expect(css).toContain('color-scheme: light;');
|
||||
// Body inherits the theme foreground so unstyled plugin text adapts.
|
||||
expect(css).toContain('color: var(--color-foreground, inherit);');
|
||||
expect(css).toContain('background: transparent;');
|
||||
});
|
||||
|
||||
it('reports a dark color-scheme when dark', () => {
|
||||
const css = themeSnapshotToCSS({ dark: true, fontFamily: 'sans-serif', vars: {} });
|
||||
expect(css).toContain('color-scheme: dark;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotHostTheme', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.removeAttribute('style');
|
||||
});
|
||||
|
||||
it('reads the dark flag and declared tokens off <html>', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.setProperty('--color-background', '#0a0a0a');
|
||||
const snapshot = snapshotHostTheme();
|
||||
expect(snapshot.dark).toBe(true);
|
||||
expect(snapshot.vars['--color-background']).toBe('#0a0a0a');
|
||||
expect(snapshot.fontFamily).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { OAuthMetadata } from '../oauth/discovery';
|
||||
|
||||
// Capture status/headers from NextResponse.json (the shared config-route mock
|
||||
// drops them, but this route's behavior depends on the status code).
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number; headers?: Record<string, string> }) => ({
|
||||
json: async () => data,
|
||||
status: init?.status ?? 200,
|
||||
headers: init?.headers ?? {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
const getMetadata = vi.fn();
|
||||
const getRequiredConfig = vi.fn();
|
||||
vi.mock('@/lib/oauth/token-exchange', () => ({
|
||||
getMetadata: (...args: unknown[]) => getMetadata(...args),
|
||||
getRequiredConfig: (...args: unknown[]) => getRequiredConfig(...args),
|
||||
}));
|
||||
|
||||
const VALID_METADATA: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
};
|
||||
|
||||
function mockRequest(serverId?: string): unknown {
|
||||
return {
|
||||
nextUrl: {
|
||||
searchParams: { get: (k: string) => (k === 'server_id' ? serverId ?? null : null) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function callRoute(serverId?: string) {
|
||||
const { GET } = await import('@/app/api/auth/oauth/metadata/route');
|
||||
const res = (await GET(mockRequest(serverId) as Parameters<typeof GET>[0])) as unknown as {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
json: () => Promise<Record<string, unknown>>;
|
||||
};
|
||||
return { status: res.status, headers: res.headers, body: await res.json() };
|
||||
}
|
||||
|
||||
describe('oauth metadata route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getRequiredConfig.mockReturnValue({ discoveryUrl: 'https://auth.example.com' });
|
||||
});
|
||||
|
||||
it('returns discovered metadata for the resolved server', async () => {
|
||||
getMetadata.mockResolvedValue(VALID_METADATA);
|
||||
|
||||
const { status, body, headers } = await callRoute('server-1');
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(VALID_METADATA);
|
||||
expect(headers['Cache-Control']).toContain('max-age=600');
|
||||
expect(getMetadata).toHaveBeenCalledWith('server-1');
|
||||
});
|
||||
|
||||
it('returns 404 when OAuth is not configured', async () => {
|
||||
getRequiredConfig.mockImplementation(() => {
|
||||
throw new Error('OAuth misconfigured: OAUTH_CLIENT_ID not set');
|
||||
});
|
||||
|
||||
const { status, body } = await callRoute();
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(body).toEqual({ error: 'OAuth not configured' });
|
||||
expect(getMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 502 when discovery yields no usable endpoints', async () => {
|
||||
getMetadata.mockResolvedValue(null);
|
||||
|
||||
const { status, body } = await callRoute();
|
||||
|
||||
expect(status).toBe(502);
|
||||
expect(body).toEqual({ error: 'OAuth discovery failed' });
|
||||
});
|
||||
|
||||
it('returns 502 when discovery throws', async () => {
|
||||
getMetadata.mockRejectedValue(new Error('network down'));
|
||||
|
||||
const { status, body } = await callRoute();
|
||||
|
||||
expect(status).toBe(502);
|
||||
expect(body).toEqual({ error: 'OAuth discovery failed' });
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
import {
|
||||
CALENDAR_COLORS,
|
||||
sharedCalendarColorKey,
|
||||
pickUnusedCalendarColor,
|
||||
} from '../shared-calendar-colors';
|
||||
|
||||
function makeCal(overrides: Partial<Calendar>): Calendar {
|
||||
return {
|
||||
id: 'cal-1',
|
||||
name: 'Cal',
|
||||
description: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: false,
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: {} as Calendar['myRights'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sharedCalendarColorKey', () => {
|
||||
it('is built from local account, JMAP account, and the original id', () => {
|
||||
const cal = makeCal({
|
||||
id: 'acct-9:cal-7',
|
||||
originalId: 'cal-7',
|
||||
accountId: 'acct-9',
|
||||
localAccountId: 'slot-2',
|
||||
});
|
||||
expect(sharedCalendarColorKey(cal)).toBe('slot-2|acct-9|cal-7');
|
||||
});
|
||||
|
||||
it('is stable regardless of the Pro-shell id prefix', () => {
|
||||
// Same underlying calendar, shown once under the active account (bare id)
|
||||
// and once cross-account (prefixed id) - both must map to one key.
|
||||
const active = makeCal({ id: 'acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
|
||||
const prefixed = makeCal({ id: 'slot-2::acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
|
||||
expect(sharedCalendarColorKey(active)).toBe(sharedCalendarColorKey(prefixed));
|
||||
});
|
||||
|
||||
it('falls back to the id when originalId is absent', () => {
|
||||
const cal = makeCal({ id: 'cal-7', accountId: 'acct-9' });
|
||||
expect(sharedCalendarColorKey(cal)).toBe('|acct-9|cal-7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickUnusedCalendarColor', () => {
|
||||
it('returns a palette color not present in usedColors', () => {
|
||||
const used = CALENDAR_COLORS.slice(0, CALENDAR_COLORS.length - 1);
|
||||
const picked = pickUnusedCalendarColor(used);
|
||||
expect(picked).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
|
||||
});
|
||||
|
||||
it('ignores case when comparing used colors', () => {
|
||||
const used = CALENDAR_COLORS.slice(0, -1).map((c) => c.toUpperCase());
|
||||
expect(pickUnusedCalendarColor(used)).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
|
||||
});
|
||||
|
||||
it('still returns a palette color once every color is taken', () => {
|
||||
expect(CALENDAR_COLORS).toContain(pickUnusedCalendarColor(CALENDAR_COLORS));
|
||||
});
|
||||
|
||||
it('always returns a valid palette color for a small used set', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const picked = pickUnusedCalendarColor(['#3b82f6']);
|
||||
expect(CALENDAR_COLORS).toContain(picked);
|
||||
expect(picked).not.toBe('#3b82f6');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendHtmlSignature,
|
||||
appendPlainTextSignature,
|
||||
getPlainTextSignature,
|
||||
hasMeaningfulHtmlBody,
|
||||
@@ -28,27 +27,6 @@ describe('signature-utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendHtmlSignature', () => {
|
||||
it('appends a sanitized html signature, preserving formatting', () => {
|
||||
expect(appendHtmlSignature('<div>Hello</div>', { htmlSignature: '<strong>Alice</strong>' }))
|
||||
.toBe('<div>Hello</div><br><br>-- <br><strong>Alice</strong>');
|
||||
});
|
||||
|
||||
it('escapes and appends a text signature when no html signature exists', () => {
|
||||
expect(appendHtmlSignature('<div>Hello</div>', { textSignature: 'Alice\nEng' }))
|
||||
.toBe('<div>Hello</div><br><br>-- <br>Alice<br>Eng');
|
||||
});
|
||||
|
||||
it('omits the separator marker when disabled', () => {
|
||||
expect(appendHtmlSignature('<div>Hi</div>', { htmlSignature: '<strong>A</strong>' }, { separator: false }))
|
||||
.toBe('<div>Hi</div><br><br><strong>A</strong>');
|
||||
});
|
||||
|
||||
it('leaves the body untouched when no signature exists', () => {
|
||||
expect(appendHtmlSignature('<div>Hi</div>', {})).toBe('<div>Hi</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMeaningfulHtmlBody', () => {
|
||||
it('prefers html bodies that preserve signature formatting', () => {
|
||||
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* Per-domain branding overrides: schema, parsing, host extraction, and match.
|
||||
*
|
||||
* The webmail can be served on multiple hostnames (e.g. mail1.example.com,
|
||||
* mail2.other.com). Each hostname can override a subset of branding fields;
|
||||
* unset fields fall back to the global admin/env/default value.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/** Config keys that can be overridden per domain. */
|
||||
export const BRANDING_OVERRIDE_KEYS = [
|
||||
'appName',
|
||||
'appShortName',
|
||||
'appDescription',
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
'pwaThemeColor',
|
||||
'pwaBackgroundColor',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'loginCompanyName',
|
||||
'loginImprintUrl',
|
||||
'loginPrivacyPolicyUrl',
|
||||
'loginWebsiteUrl',
|
||||
] as const;
|
||||
|
||||
export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number];
|
||||
|
||||
export interface DomainBrandingEntry {
|
||||
/**
|
||||
* Hostname this entry applies to. Either an exact host like
|
||||
* "mail.example.com" or a wildcard like "*.example.com" (matches any
|
||||
* direct or deeper subdomain). Case-insensitive; trailing dots are
|
||||
* stripped on parse.
|
||||
*/
|
||||
host: string;
|
||||
appName?: string;
|
||||
appShortName?: string;
|
||||
appDescription?: string;
|
||||
faviconUrl?: string;
|
||||
pwaIconUrl?: string;
|
||||
pwaScreenshotMobileUrl?: string;
|
||||
pwaScreenshotDesktopUrl?: string;
|
||||
pwaThemeColor?: string;
|
||||
pwaBackgroundColor?: string;
|
||||
appLogoLightUrl?: string;
|
||||
appLogoDarkUrl?: string;
|
||||
loginLogoLightUrl?: string;
|
||||
loginLogoDarkUrl?: string;
|
||||
loginCompanyName?: string;
|
||||
loginImprintUrl?: string;
|
||||
loginPrivacyPolicyUrl?: string;
|
||||
loginWebsiteUrl?: string;
|
||||
}
|
||||
|
||||
// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards
|
||||
// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo,
|
||||
// and embedded wildcards.
|
||||
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */
|
||||
export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] {
|
||||
if (!raw) return [];
|
||||
let value = raw;
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return [];
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: DomainBrandingEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
const rawHost = typeof rec.host === 'string' ? rec.host : '';
|
||||
const host = normalizeHost(rawHost);
|
||||
if (!host || !HOST_RE.test(host)) continue;
|
||||
if (seen.has(host)) continue;
|
||||
seen.add(host);
|
||||
|
||||
const entry: DomainBrandingEntry = { host };
|
||||
const writable = entry as unknown as Record<string, string>;
|
||||
for (const key of BRANDING_OVERRIDE_KEYS) {
|
||||
const v = rec[key];
|
||||
if (typeof v === 'string' && v.length > 0) {
|
||||
writable[key] = v;
|
||||
}
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type HeadersLike = Headers | { get(name: string): string | null };
|
||||
|
||||
/**
|
||||
* Pick the request's host, preferring X-Forwarded-Host (first entry if
|
||||
* comma-separated) over Host. Strips the port. Returns null when no usable
|
||||
* host header is set.
|
||||
*/
|
||||
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
|
||||
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
|
||||
const raw = headers.get('x-forwarded-host') || headers.get('host');
|
||||
if (!raw) return null;
|
||||
const first = raw.split(',')[0]?.trim();
|
||||
if (!first) return null;
|
||||
return normalizeHost(first.split(':')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the entry whose host matches `host`. Exact match always wins; among
|
||||
* wildcards the longest (most-specific) suffix wins. Returns {} when no
|
||||
* entry matches.
|
||||
*/
|
||||
export function matchDomainBranding(
|
||||
host: string | null,
|
||||
entries: DomainBrandingEntry[],
|
||||
): Partial<DomainBrandingEntry> {
|
||||
if (!host || entries.length === 0) return {};
|
||||
const lower = normalizeHost(host);
|
||||
let wildcardMatch: DomainBrandingEntry | undefined;
|
||||
for (const entry of entries) {
|
||||
if (entry.host === lower) return entry;
|
||||
if (entry.host.startsWith('*.')) {
|
||||
const suffix = entry.host.slice(1); // ".example.com"
|
||||
if (lower.endsWith(suffix) && lower.length > suffix.length) {
|
||||
if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) {
|
||||
wildcardMatch = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return wildcardMatch ?? {};
|
||||
}
|
||||
@@ -98,7 +98,7 @@ async function migrateAdminJson(): Promise<boolean> {
|
||||
lastLogin: data.lastLogin ?? null,
|
||||
passwordChangedAt: data.passwordChangedAt ?? now,
|
||||
};
|
||||
const configData: AdminConfigData = { passwordHash: data.passwordHash, passwordHashFile: undefined };
|
||||
const configData: AdminConfigData = { passwordHash: data.passwordHash };
|
||||
|
||||
await ensureStateDir();
|
||||
const statePath = getStatePath('admin-state.json');
|
||||
|
||||
+4
-14
@@ -9,7 +9,6 @@ import {
|
||||
assertWritable,
|
||||
} from './paths';
|
||||
import type { AdminConfigData, AdminStateData } from './types';
|
||||
import { readFileEnv } from '../read-file-env';
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_COST = 16384; // 2^14
|
||||
@@ -147,7 +146,7 @@ export async function initAdminPassword(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
||||
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cachedConfig = { passwordHash: hash };
|
||||
cachedState = freshState();
|
||||
await writeConfigData(cachedConfig);
|
||||
await writeStateData(cachedState);
|
||||
@@ -166,16 +165,7 @@ export async function initAdminPassword(): Promise<boolean> {
|
||||
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
||||
if (!cachedConfig) cachedConfig = await readConfigData();
|
||||
if (!cachedConfig) return false;
|
||||
if (cachedConfig.passwordHash) {
|
||||
return verifyPassword(password, cachedConfig.passwordHash);
|
||||
} else if (cachedConfig.passwordHashFile) {
|
||||
let passwordHash = readFileEnv(cachedConfig.passwordHashFile);
|
||||
if (passwordHash) {
|
||||
return verifyPassword(password, passwordHash);
|
||||
}
|
||||
}
|
||||
logger.error('The admin password hash could neither be retrieved from passwordHash nor from passwordHashFile in admin.json');
|
||||
return false;
|
||||
return verifyPassword(password, cachedConfig.passwordHash);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +176,7 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
||||
if (!valid) return false;
|
||||
|
||||
const hash = await hashPassword(newPassword);
|
||||
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cachedConfig = { passwordHash: hash };
|
||||
await writeConfigData(cachedConfig);
|
||||
|
||||
cachedState = {
|
||||
@@ -215,7 +205,7 @@ export async function setInitialAdminPassword(
|
||||
const existing = await readConfigData();
|
||||
if (existing && !options.allowOverwrite) return false;
|
||||
const hash = await hashPassword(newPassword);
|
||||
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cachedConfig = { passwordHash: hash };
|
||||
cachedState = freshState();
|
||||
await writeConfigData(cachedConfig);
|
||||
await writeStateData(cachedState);
|
||||
|
||||
@@ -53,13 +53,6 @@ export interface ServerPlugin {
|
||||
forceEnabled?: boolean;
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
settingsSchema?: Record<string, PluginSettingsField>;
|
||||
/**
|
||||
* Optional per-locale translation tables (locale -> key -> string) declared
|
||||
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
|
||||
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
|
||||
* default language.
|
||||
*/
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
|
||||
+1
-12
@@ -6,8 +6,7 @@
|
||||
* is config; mutable timestamps live in AdminStateData.
|
||||
*/
|
||||
export interface AdminConfigData {
|
||||
passwordHash: string | undefined;
|
||||
passwordHashFile: string | undefined;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,10 +106,6 @@ export interface SettingsPolicy {
|
||||
approvedPlugins: string[];
|
||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||
forceEnabledThemes: string[];
|
||||
/** Web Push relay base URL shown to users. Empty means the built-in default. */
|
||||
pushRelayUrl?: string;
|
||||
/** When true, users cannot change pushRelayUrl in notification settings. */
|
||||
pushRelayUrlLocked?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
@@ -121,8 +116,6 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
forceEnabledPlugins: [],
|
||||
approvedPlugins: [],
|
||||
forceEnabledThemes: [],
|
||||
pushRelayUrl: '',
|
||||
pushRelayUrlLocked: false,
|
||||
};
|
||||
|
||||
export interface AuditEntry {
|
||||
@@ -137,15 +130,12 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
|
||||
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
|
||||
searchEngineIndexing: { envVar: 'SEARCH_ENGINE_INDEXING', type: 'boolean', defaultValue: false },
|
||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||||
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
|
||||
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
|
||||
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||||
@@ -167,7 +157,6 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||
|
||||
@@ -23,12 +23,7 @@ export function getSessionSecret(): string {
|
||||
if (fromFile) return fromFile;
|
||||
|
||||
const fromAdmin = configManager.get<string>('sessionSecret', '');
|
||||
if (fromAdmin) return fromAdmin;
|
||||
|
||||
const fromAdminFile = readFileEnv(
|
||||
configManager.get<string>('sessionSecretFile', ''),
|
||||
);
|
||||
return fromAdminFile || "";
|
||||
return fromAdmin || '';
|
||||
}
|
||||
|
||||
export function hasSessionSecret(): boolean {
|
||||
|
||||
@@ -328,552 +328,6 @@ const solarizedCSS = `
|
||||
--color-chart-5: #6c71c4;
|
||||
}`;
|
||||
|
||||
// Roundcube "Elastic" skin recreation.
|
||||
//
|
||||
// Colour tokens are lifted from skins/elastic/styles/colors.less (CC BY-SA):
|
||||
// accent #37beff font #27353a border #ddd
|
||||
// error #ff5552 success #41b849 warning #ffd452
|
||||
// task-menu #2f3a3f (always dark, both modes)
|
||||
// list-select tint(#37beff, 90%) -> #ebf8ff
|
||||
// Dark mode mirrors @color-dark-* (background #21292c, font #c5d1d3, …).
|
||||
const elasticCSS = `
|
||||
:root {
|
||||
--color-border: #dddddd;
|
||||
--color-input: #ced4da;
|
||||
--color-ring: #37beff;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #27353a;
|
||||
--color-primary: #37beff;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f4f4f4;
|
||||
--color-secondary-foreground: #27353a;
|
||||
--color-muted: #f4f4f4;
|
||||
--color-muted-foreground: #737677;
|
||||
--color-accent: #e6f6ff;
|
||||
--color-accent-foreground: #0070a8;
|
||||
--color-destructive: #ff5552;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #27353a;
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-foreground: #27353a;
|
||||
--color-sidebar-border: #dddddd;
|
||||
--color-sidebar-accent: #ebf8ff;
|
||||
--color-sidebar-accent-foreground: #27353a;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #27353a;
|
||||
--color-success: #41b849;
|
||||
--color-success-foreground: #ffffff;
|
||||
--color-warning: #ffd452;
|
||||
--color-warning-foreground: #27353a;
|
||||
--color-info: #37beff;
|
||||
--color-info-foreground: #ffffff;
|
||||
--color-selection: #ebf8ff;
|
||||
--color-selection-foreground: #27353a;
|
||||
--color-unread: #ffd452;
|
||||
--color-chart-1: #37beff;
|
||||
--color-chart-2: #41b849;
|
||||
--color-chart-3: #ffd452;
|
||||
--color-chart-4: #ff5552;
|
||||
--color-chart-5: #9b59b6;
|
||||
/* Elastic is a 14px Roboto skin with tighter list rows than Bulwark's default */
|
||||
--font-size-base: 14px;
|
||||
--list-item-height: 40px;
|
||||
}
|
||||
.dark {
|
||||
--color-border: #4d6066;
|
||||
--color-input: #4d6066;
|
||||
--color-ring: #37beff;
|
||||
--color-background: #21292c;
|
||||
--color-foreground: #ffffff;
|
||||
--color-primary: #37beff;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #2c373a;
|
||||
--color-secondary-foreground: #ffffff;
|
||||
--color-muted: #2c373a;
|
||||
/* Roundcube keeps most text near the bright font colour, only true hints
|
||||
dim out. Bulwark applies muted-foreground far more widely, so brighten it
|
||||
toward @color-dark-font (#c5d1d3) to match Elastic's overall brightness. */
|
||||
--color-muted-foreground: #b3bec1;
|
||||
--color-accent: #374549;
|
||||
--color-accent-foreground: #37beff;
|
||||
--color-destructive: #ff5552;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-popover: #161b1d;
|
||||
--color-popover-foreground: #ffffff;
|
||||
--color-sidebar: #21292c;
|
||||
--color-sidebar-foreground: #ffffff;
|
||||
--color-sidebar-border: #4d6066;
|
||||
--color-sidebar-accent: #374549;
|
||||
--color-sidebar-accent-foreground: #c5d1d3;
|
||||
--color-card: #1a2225;
|
||||
--color-card-foreground: #ffffff;
|
||||
--color-success: #41b849;
|
||||
--color-success-foreground: #ffffff;
|
||||
--color-warning: #ffd452;
|
||||
--color-warning-foreground: #21292c;
|
||||
--color-info: #37beff;
|
||||
--color-info-foreground: #ffffff;
|
||||
--color-selection: #374549;
|
||||
--color-selection-foreground: #37beff;
|
||||
--color-unread: #b88a00;
|
||||
--color-chart-1: #37beff;
|
||||
--color-chart-2: #41b849;
|
||||
--color-chart-3: #ffd452;
|
||||
--color-chart-4: #ff5552;
|
||||
--color-chart-5: #b48ead;
|
||||
}`;
|
||||
|
||||
// Component-level overrides that the colour tokens alone can't express:
|
||||
// Roboto typography and Elastic's signature dark "task menu" rail (which is
|
||||
// dark in both light and dark mode). Scoped under the skin body attribute so
|
||||
// it cleanly detaches when the theme is switched off.
|
||||
const elasticSkin = `
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] {
|
||||
font-family: Roboto, "Helvetica Neue", "Segoe UI", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
|
||||
/* ── Task menu (left navigation rail) ─────────────────────────── */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary {
|
||||
background-color: #2f3a3f !important;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
/* In dark mode the black hairline vanishes against the dark canvas - use a
|
||||
light one so the rail still reads as a distinct column. (.dark lives on
|
||||
<html>, so it must be an ancestor of the skinned <body>.) */
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary {
|
||||
border-right-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
/* Light icons + labels on the dark rail */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-muted-foreground {
|
||||
color: #e7edee !important;
|
||||
}
|
||||
/* Hover slab */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a:hover,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button:hover {
|
||||
background-color: #41525a !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
/* Selected task: brighter slab, accent-blue glyph (matches Elastic) */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .bg-primary\\/10 {
|
||||
background-color: #41525a !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-primary {
|
||||
color: #37beff !important;
|
||||
}
|
||||
|
||||
/* ── Flatten Bulwark's soft radii to Elastic's Bootstrap-flat look ── */
|
||||
/* Elastic uses ~4px corners on buttons/inputs/cards; rounded-full (pills,
|
||||
avatars, toggles) is intentionally left alone. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-md,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-lg,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-xl,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-2xl,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-3xl,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] input,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] textarea,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] button:not(.rounded-full) {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
/* ── Unify panel backgrounds like Roundcube ───────────────────── */
|
||||
/* In Elastic the folder list, message list and content pane all share one
|
||||
canvas (white / dark); only the narrow task rail is dark. Bulwark's folder
|
||||
sidebar uses \`bg-secondary\` (a grey panel), so repaint it with the main
|
||||
background. The folder sidebar carries the \`border-r\` class; the dark task
|
||||
rail uses \`bg-secondary\` WITHOUT it (inline-styled border), so this
|
||||
qualifier leaves the rail dark. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r {
|
||||
background-color: var(--color-background) !important;
|
||||
}
|
||||
/* The empty "No conversation selected" pane uses a muted diagonal gradient;
|
||||
flatten it to the plain canvas so the content area is one solid colour. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
|
||||
background: var(--color-background) !important;
|
||||
}
|
||||
|
||||
/* The message-viewer body sits on a faint muted backing (bg-muted/30); flatten
|
||||
it to the plain canvas so the whole content pane - search bar, action
|
||||
toolbar, mail header and body - is one uniform background colour. The
|
||||
search/toolbar/header are bordered \`bg-background\` strips, so they already
|
||||
match once we leave them untinted. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-muted\\/30 {
|
||||
background-color: var(--color-background) !important;
|
||||
}
|
||||
|
||||
/* ── Elastic primary buttons (.btn-primary) ──────────────────── */
|
||||
/* Solid accent fill, white label + icon, hairline border, focus halo. Light
|
||||
= @color-main (#37beff); dark = @color-dark-main (darken 30% -> #006a9d)
|
||||
with the bright #37beff outline, exactly like the on-switch. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground {
|
||||
background-color: #37beff !important;
|
||||
border: 1px solid #37beff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover {
|
||||
background-color: #19b6fe !important;
|
||||
border-color: #0bb0ff !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:focus-visible {
|
||||
box-shadow: 0 0 0 0.2rem rgba(55, 190, 255, 0.3) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground {
|
||||
background-color: #006a9d !important;
|
||||
border-color: #37beff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover {
|
||||
background-color: #0079b3 !important;
|
||||
}
|
||||
|
||||
/* ── Elastic on/off switch (.custom-switch) ──────────────────── */
|
||||
/* Track + knob restyled to the Bootstrap-derived Elastic switch. The toggle
|
||||
is a [role="switch"] button with a single inner <span> knob. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] {
|
||||
background-color: #ced4da !important;
|
||||
border: 1px solid #b8c0c8 !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] {
|
||||
background-color: #37beff !important;
|
||||
border-color: #37beff !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] {
|
||||
background-color: #4d6066 !important;
|
||||
border-color: #5d7077 !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span {
|
||||
background-color: #c5d1d3 !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] {
|
||||
background-color: #006a9d !important;
|
||||
border-color: #37beff !important;
|
||||
}
|
||||
|
||||
/* ── Recipient name/email rendered as links (like Roundcube) ──── */
|
||||
/* Sender + recipient triggers (RecipientPopover) sit at \`text-foreground\`
|
||||
and only turn blue on hover; Elastic shows them link-blue at rest
|
||||
(@color-link #00acff, brighter in dark). The three-class combo is unique
|
||||
to these recipient buttons. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer {
|
||||
color: #00acff !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer {
|
||||
color: #37beff !important;
|
||||
}
|
||||
/* The sender's email address printed under the name is muted grey; Roundcube
|
||||
shows it link-blue too. It's the div immediately after the name row
|
||||
(.flex.items-center.flex-wrap), which the contact-card org line is not, so
|
||||
this sibling selector scopes it to the sender email only. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate {
|
||||
color: #00acff !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate {
|
||||
color: #37beff !important;
|
||||
}
|
||||
|
||||
/* ── Elastic context / popup menus ───────────────────────────── */
|
||||
/* Roundcube highlights the hovered menu entry with a solid accent fill and
|
||||
white text (@color-menu-hover-background: @color-main, @color-menu-hover:
|
||||
#fff) - the same in light and dark. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus-visible {
|
||||
background-color: #37beff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
/* Icons (currentColor) and muted accessories (shortcuts, submenu chevron)
|
||||
follow the white text on hover. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover svg,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus svg,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover .text-muted-foreground {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* ── Elastic folder list: unread count as a rounded pill ─────── */
|
||||
/* Roundcube shows the unread count in a small grey pill on the right, and no
|
||||
badge at all when a folder has nothing unread. Bulwark renders plain
|
||||
"unread / total" text; the counts container is
|
||||
span.ml-2.flex-shrink-0.gap-1.items-baseline holding an unread span
|
||||
(.font-semibold), an optional "/" (.text-muted-foreground/60) and the total
|
||||
(.text-muted-foreground). Reshape it into a pill, drop the total + slash,
|
||||
and hide the whole badge when there's no unread span. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline {
|
||||
align-items: center !important;
|
||||
justify-content: center;
|
||||
min-width: 1.6rem;
|
||||
height: 1.2rem;
|
||||
padding: 0 0.45rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: #e4e8ea;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground,
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground\\/60 {
|
||||
display: none !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span {
|
||||
color: #5e6b70 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
/* No unread span -> no badge (matches Sent/Drafts in Roundcube). */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline:not(:has(span.font-semibold)) {
|
||||
display: none !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline {
|
||||
background-color: #3f4e55;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span {
|
||||
color: #c9d3d5 !important;
|
||||
}
|
||||
|
||||
/* A slightly bolder accent bar on the selected folder, like Elastic. */
|
||||
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r .border-l-2.border-primary {
|
||||
border-left-width: 3px !important;
|
||||
}`;
|
||||
|
||||
// "Aurora Glass" - an ultra-modern glassmorphism skin. Vibrant indigo→cyan
|
||||
// accents float over a fixed gradient-mesh canvas; every structural surface
|
||||
// (nav rail, folder sidebar, cards, popovers, menus, dialogs, toolbars) is a
|
||||
// translucent frosted pane with heavy backdrop-blur + saturation, hairline
|
||||
// luminous borders and soft glow on the primary action.
|
||||
//
|
||||
// The colour tokens stay near-solid for legible text contrast; the frosted
|
||||
// translucency + blur all live in the skin, where a single selector can pair
|
||||
// an rgba background with the matching backdrop-filter.
|
||||
const auroraCSS = `
|
||||
:root {
|
||||
--color-border: rgba(13, 18, 45, 0.10);
|
||||
--color-input: rgba(13, 18, 45, 0.12);
|
||||
--color-ring: #6d5cff;
|
||||
--color-background: #eef1fb;
|
||||
--color-foreground: #14162e;
|
||||
--color-primary: #6d5cff;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #e3e7f7;
|
||||
--color-secondary-foreground: #14162e;
|
||||
--color-muted: #e7eaf6;
|
||||
--color-muted-foreground: #5b6080;
|
||||
--color-accent: #e0e7ff;
|
||||
--color-accent-foreground: #4338ca;
|
||||
--color-destructive: #f43f5e;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #14162e;
|
||||
--color-sidebar: #eef1fb;
|
||||
--color-sidebar-foreground: #14162e;
|
||||
--color-sidebar-border: rgba(13, 18, 45, 0.08);
|
||||
--color-sidebar-accent: rgba(109, 92, 255, 0.10);
|
||||
--color-sidebar-accent-foreground: #4338ca;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #14162e;
|
||||
--color-success: #10b981;
|
||||
--color-success-foreground: #ffffff;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-foreground: #14162e;
|
||||
--color-info: #06b6d4;
|
||||
--color-info-foreground: #ffffff;
|
||||
--color-selection: rgba(109, 92, 255, 0.14);
|
||||
--color-selection-foreground: #4338ca;
|
||||
--color-unread: #6d5cff;
|
||||
--color-chart-1: #6d5cff;
|
||||
--color-chart-2: #06b6d4;
|
||||
--color-chart-3: #ec4899;
|
||||
--color-chart-4: #f59e0b;
|
||||
--color-chart-5: #10b981;
|
||||
}
|
||||
.dark {
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-input: rgba(255, 255, 255, 0.10);
|
||||
--color-ring: #8b7bff;
|
||||
--color-background: #06070f;
|
||||
--color-foreground: #eef0ff;
|
||||
--color-primary: #8b7bff;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: rgba(255, 255, 255, 0.06);
|
||||
--color-secondary-foreground: #eef0ff;
|
||||
--color-muted: rgba(255, 255, 255, 0.05);
|
||||
--color-muted-foreground: #9aa0c4;
|
||||
--color-accent: rgba(139, 123, 255, 0.16);
|
||||
--color-accent-foreground: #c4bbff;
|
||||
--color-destructive: #fb7185;
|
||||
--color-destructive-foreground: #1a0b10;
|
||||
--color-popover: #12132a;
|
||||
--color-popover-foreground: #eef0ff;
|
||||
--color-sidebar: #08091a;
|
||||
--color-sidebar-foreground: #eef0ff;
|
||||
--color-sidebar-border: rgba(255, 255, 255, 0.07);
|
||||
--color-sidebar-accent: rgba(139, 123, 255, 0.18);
|
||||
--color-sidebar-accent-foreground: #c4bbff;
|
||||
--color-card: rgba(255, 255, 255, 0.04);
|
||||
--color-card-foreground: #eef0ff;
|
||||
--color-success: #34d399;
|
||||
--color-success-foreground: #052e1f;
|
||||
--color-warning: #fbbf24;
|
||||
--color-warning-foreground: #1a1405;
|
||||
--color-info: #22d3ee;
|
||||
--color-info-foreground: #04222a;
|
||||
--color-selection: rgba(139, 123, 255, 0.22);
|
||||
--color-selection-foreground: #c4bbff;
|
||||
--color-unread: #8b7bff;
|
||||
--color-chart-1: #8b7bff;
|
||||
--color-chart-2: #22d3ee;
|
||||
--color-chart-3: #f472b6;
|
||||
--color-chart-4: #fbbf24;
|
||||
--color-chart-5: #34d399;
|
||||
}`;
|
||||
|
||||
// Glassmorphism skin: the frosted translucency, backdrop-blur, gradient-mesh
|
||||
// canvas, luminous borders and glow that the colour tokens alone can't carry.
|
||||
// Scoped under the skin body attribute so it detaches cleanly on switch-off.
|
||||
const auroraSkin = `
|
||||
body[data-theme-skin="builtin-aurora-glass"] {
|
||||
font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ── Gradient-mesh canvas ──────────────────────────────────────── */
|
||||
/* A multi-stop radial mesh painted onto the app-shell root (the unique
|
||||
.bg-background.overflow-hidden container) so the frosted panes have
|
||||
something luminous to blur. Painting it directly on the shell - rather than
|
||||
a fixed body::before with z-indexed children - avoids creating stacking
|
||||
contexts on portaled popups/dialogs (Radix portals render as direct <body>
|
||||
children), which would otherwise break their layering. */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden {
|
||||
background-color: var(--color-background);
|
||||
background-image:
|
||||
radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.22), transparent 60%),
|
||||
radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.18), transparent 55%),
|
||||
radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.16), transparent 60%),
|
||||
radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.12), transparent 55%);
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden {
|
||||
background-image:
|
||||
radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.16), transparent 58%),
|
||||
radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.11), transparent 52%),
|
||||
radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.10), transparent 58%),
|
||||
radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.07), transparent 52%);
|
||||
}
|
||||
|
||||
/* ── Pill-soft radii everywhere ───────────────────────────────── */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .rounded-md,
|
||||
body[data-theme-skin="builtin-aurora-glass"] .rounded-lg {
|
||||
border-radius: 0.85rem !important;
|
||||
}
|
||||
body[data-theme-skin="builtin-aurora-glass"] .rounded-xl,
|
||||
body[data-theme-skin="builtin-aurora-glass"] .rounded-2xl {
|
||||
border-radius: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* ── Frosted panes: the shared glass recipe ───────────────────── */
|
||||
/* Nav rail, folder sidebar, content backing, cards, popovers, dialogs and
|
||||
menus all share one frosted treatment - translucent fill + heavy backdrop
|
||||
blur + saturation so the mesh and neighbours bleed through softly. */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary,
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r {
|
||||
background-color: rgba(255, 255, 255, 0.55) !important;
|
||||
backdrop-filter: blur(26px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(180%);
|
||||
border-color: rgba(13, 18, 45, 0.08) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary,
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r {
|
||||
background-color: rgba(8, 9, 22, 0.82) !important;
|
||||
border-color: rgba(255, 255, 255, 0.07) !important;
|
||||
}
|
||||
|
||||
/* The "no conversation selected" empty pane sits inside an opaque
|
||||
bg-background column, so it can't inherit the shell mesh - paint the same
|
||||
gradient mesh directly onto it so the placeholder reads as glass too. */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
|
||||
background-color: var(--color-background) !important;
|
||||
background-image:
|
||||
radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.22), transparent 60%),
|
||||
radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.18), transparent 55%),
|
||||
radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.16), transparent 60%) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
|
||||
background-image:
|
||||
radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.18), transparent 58%),
|
||||
radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.12), transparent 52%),
|
||||
radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.11), transparent 58%) !important;
|
||||
}
|
||||
/* The reading-pane message backing goes clear glass so the frosted panes and
|
||||
gradient bleed through softly. */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-muted\\/30 {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Cards, popovers and dialogs: brighter frosted glass with a luminous edge. */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-card,
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-popover,
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="menu"],
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="dialog"],
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] {
|
||||
background-color: rgba(255, 255, 255, 0.72) !important;
|
||||
backdrop-filter: blur(28px) saturate(190%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(190%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
||||
box-shadow: 0 12px 40px -12px rgba(20, 22, 46, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-card,
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-popover,
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"],
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="dialog"],
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] {
|
||||
background-color: rgba(16, 17, 38, 0.86) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10) !important;
|
||||
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* ── Primary action: gradient fill + glow ─────────────────────── */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground {
|
||||
background-image: linear-gradient(135deg, #7c5cff 0%, #6d5cff 45%, #4f9bff 100%) !important;
|
||||
border: none !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 6px 20px -4px rgba(109, 92, 255, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.35) !important;
|
||||
transition: box-shadow 160ms ease, filter 160ms ease;
|
||||
}
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground:hover {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 10px 30px -4px rgba(109, 92, 255, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.4) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground {
|
||||
background-image: linear-gradient(135deg, #8b7bff 0%, #6d5cff 50%, #22d3ee 130%) !important;
|
||||
box-shadow: 0 6px 24px -4px rgba(139, 123, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
/* ── Selected folder / row: soft tinted glass + accent bar ────── */
|
||||
body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r .border-l-2.border-primary {
|
||||
border-left-width: 3px !important;
|
||||
}
|
||||
|
||||
/* ── On/off switch: gradient when active ──────────────────────── */
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] {
|
||||
background-image: linear-gradient(135deg, #7c5cff, #4f9bff) !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: 0 2px 10px -1px rgba(109, 92, 255, 0.6) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] {
|
||||
background-image: linear-gradient(135deg, #8b7bff, #22d3ee) !important;
|
||||
}
|
||||
|
||||
/* ── Menu hover: tinted accent slab (keeps glass legible) ─────── */
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover,
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus,
|
||||
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible {
|
||||
background-color: rgba(109, 92, 255, 0.14) !important;
|
||||
color: var(--color-foreground) !important;
|
||||
}
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover,
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus,
|
||||
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible {
|
||||
background-color: rgba(139, 123, 255, 0.22) !important;
|
||||
}`;
|
||||
|
||||
export const BUILTIN_THEMES: InstalledTheme[] = [
|
||||
{
|
||||
id: 'builtin-qui',
|
||||
@@ -919,30 +373,4 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
|
||||
enabled: true,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin-roundcube-elastic',
|
||||
name: 'Roundcube Elastic',
|
||||
version: '1.0.0',
|
||||
author: 'Built-in',
|
||||
description: 'Faithful recreation of Roundcube\'s Elastic skin - Roboto, the #37beff blue, and the dark task-menu rail',
|
||||
css: elasticCSS,
|
||||
skin: elasticSkin,
|
||||
variants: ['light', 'dark'],
|
||||
typography: { fontSans: 'Roboto, "Helvetica Neue", Arial, sans-serif', baseFontSize: '14px' },
|
||||
enabled: true,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin-aurora-glass',
|
||||
name: 'Aurora Glass',
|
||||
version: '1.0.0',
|
||||
author: 'Built-in',
|
||||
description: 'Ultra-modern glassmorphism - frosted blurred panes floating over a vibrant indigo→cyan gradient mesh, with glowing gradient actions',
|
||||
css: auroraCSS,
|
||||
skin: auroraSkin,
|
||||
variants: ['light', 'dark'],
|
||||
typography: { fontSans: '"Inter", "SF Pro Display", "Segoe UI", system-ui, sans-serif' },
|
||||
enabled: true,
|
||||
builtIn: true,
|
||||
},
|
||||
];
|
||||
|
||||
+1
-25
@@ -329,7 +329,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async emptyMailbox(mailboxId: string, _accountId?: string): Promise<number> {
|
||||
async emptyMailbox(mailboxId: string): Promise<number> {
|
||||
const before = this.data.emails.length;
|
||||
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||
const removed = before - this.data.emails.length;
|
||||
@@ -530,14 +530,6 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return { blobId, size: file.size, type: file.type };
|
||||
}
|
||||
|
||||
async importEmail(): Promise<string | null> {
|
||||
return generateDemoId('email');
|
||||
}
|
||||
|
||||
async sendReadReceipt(): Promise<void> {
|
||||
// Demo mode: no real network send.
|
||||
}
|
||||
|
||||
getBlobDownloadUrl(blobId: string): string {
|
||||
return `data:application/octet-stream;demo-blob=${blobId}`;
|
||||
}
|
||||
@@ -902,10 +894,6 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
||||
}
|
||||
|
||||
async listAllFileNodes(): Promise<FileNode[]> {
|
||||
return [...this.data.fileNodes];
|
||||
}
|
||||
|
||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||
if (ids === null) return [...this.data.fileNodes];
|
||||
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||
@@ -936,18 +924,6 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||
const updated: string[] = [];
|
||||
for (const [id, patch] of Object.entries(updates)) {
|
||||
const node = this.data.fileNodes.find(n => n.id === id);
|
||||
if (node) {
|
||||
Object.assign(node, patch, { updated: new Date().toISOString() });
|
||||
updated.push(id);
|
||||
}
|
||||
}
|
||||
return { updated, notUpdated: {} };
|
||||
}
|
||||
|
||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
const idSet = new Set(ids);
|
||||
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
||||
|
||||
+6
-66
@@ -1,83 +1,23 @@
|
||||
import { AuthenticationResults } from './jmap/types';
|
||||
import { parseUnsubscribeUrls } from './validation';
|
||||
|
||||
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
type SpfEntry = NonNullable<NonNullable<AuthenticationResults['spf']>['all']>[number];
|
||||
|
||||
/**
|
||||
* Severity ranking for SPF results. Higher = more severe / more actionable.
|
||||
* A hard `fail` is a definitive policy violation and must outrank ambiguous
|
||||
* states like `temperror`, so a spoofed message isn't softened to a
|
||||
* "temporary failure" headline when one identity hard-fails.
|
||||
*/
|
||||
const SPF_SEVERITY: Record<SpfResult, number> = {
|
||||
fail: 6,
|
||||
softfail: 5,
|
||||
permerror: 4,
|
||||
temperror: 3,
|
||||
neutral: 2,
|
||||
none: 1,
|
||||
pass: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the authentication results indicate the visible From identity can't
|
||||
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
||||
* would otherwise imply the message legitimately came from one of the user's
|
||||
* own identities (e.g. the "via <identity>" badge).
|
||||
*/
|
||||
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||
if (!auth) return false;
|
||||
// DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the
|
||||
// strongest single spoofing signal.
|
||||
if (auth.dmarc?.result === 'fail') return true;
|
||||
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
|
||||
// isn't authorized for the envelope domain.
|
||||
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
||||
*/
|
||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||
const results: AuthenticationResults = {};
|
||||
|
||||
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
type DmarcResult = 'pass' | 'fail' | 'none';
|
||||
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
||||
|
||||
// Parse SPF. A single Authentication-Results header can carry more than one
|
||||
// SPF result when the server evaluates multiple identities (HELO and MAIL
|
||||
// FROM). Collect them all and surface the most severe as the headline so a
|
||||
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`.
|
||||
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
|
||||
const spfResults: SpfEntry[] = [];
|
||||
let spfM: RegExpExecArray | null;
|
||||
while ((spfM = spfRegex.exec(header)) !== null) {
|
||||
spfResults.push({
|
||||
result: spfM[1] as SpfResult,
|
||||
identity: spfM[2] as SpfEntry['identity'],
|
||||
domain: spfM[3],
|
||||
});
|
||||
}
|
||||
if (spfResults.length > 0) {
|
||||
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
||||
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful
|
||||
// than HELO) and otherwise keep the first occurrence.
|
||||
const primary = spfResults.reduce((best, cur) => {
|
||||
if (severity(cur.result) > severity(best.result)) return cur;
|
||||
if (
|
||||
severity(cur.result) === severity(best.result) &&
|
||||
best.identity !== 'mailfrom' &&
|
||||
cur.identity === 'mailfrom'
|
||||
) return cur;
|
||||
return best;
|
||||
});
|
||||
// Parse SPF
|
||||
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
|
||||
if (spfMatch) {
|
||||
results.spf = {
|
||||
result: primary.result,
|
||||
domain: primary.domain,
|
||||
...(spfResults.length > 1 ? { all: spfResults } : {}),
|
||||
result: spfMatch[1] as SpfResult,
|
||||
domain: spfMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -1,4 +1,4 @@
|
||||
export type FilePreviewKind = 'image' | 'html' | 'eml' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
||||
export type FilePreviewKind = 'image' | 'html' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
|
||||
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
|
||||
@@ -38,12 +38,6 @@ export function getFilePreviewKind(name?: string, type?: string): FilePreviewKin
|
||||
return 'html';
|
||||
}
|
||||
|
||||
// Embedded email message (e.g. a bounce/DSN, or forward-as-attachment).
|
||||
// Rendered by parsing it and showing it like an email, not as a blob.
|
||||
if (mimeType === 'message/rfc822' || ext === 'eml') {
|
||||
return 'eml';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export interface IJMAPClient {
|
||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||
setKeyword(emailId: string, keyword: string): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||
deleteEmail(emailId: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
@@ -109,7 +109,7 @@ export interface IJMAPClient {
|
||||
accountId?: string,
|
||||
): Promise<void>;
|
||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||
emptyMailbox(mailboxId: string, accountId?: string): Promise<number>;
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
||||
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
@@ -150,30 +150,8 @@ export interface IJMAPClient {
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string,
|
||||
options?: { requestReadReceipt?: boolean },
|
||||
): Promise<SendEmailResult>;
|
||||
|
||||
importEmail(
|
||||
blobId: string,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string,
|
||||
): Promise<string | null>;
|
||||
|
||||
sendReadReceipt(params: {
|
||||
to: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
identityId: string;
|
||||
originalMessageId?: string | string[];
|
||||
originalSubject?: string;
|
||||
originalRecipient?: string;
|
||||
automatic?: boolean;
|
||||
accountId?: string;
|
||||
subject?: string;
|
||||
humanText?: string;
|
||||
}): Promise<void>;
|
||||
|
||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
||||
cancelEmailSubmission(submissionId: string): Promise<void>;
|
||||
@@ -210,8 +188,8 @@ export interface IJMAPClient {
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{ blobId: string; size: number; type: string }>;
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
|
||||
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
||||
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
||||
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
||||
@@ -306,12 +284,10 @@ export interface IJMAPClient {
|
||||
getFilesAccountId(): string;
|
||||
probeFileNodeSupport(): Promise<boolean>;
|
||||
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||
listAllFileNodes(): Promise<FileNode[]>;
|
||||
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
||||
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
|
||||
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||
|
||||
|
||||
+62
-208
@@ -1134,9 +1134,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const authResultsHeader = headersRecord['Authentication-Results'];
|
||||
if (authResultsHeader) {
|
||||
// Multiple Authentication-Results headers (or multiple SPF identities in
|
||||
// one header) must all be considered so the most severe result wins.
|
||||
const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader;
|
||||
const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader;
|
||||
email.authenticationResults = parseAuthenticationResults(value);
|
||||
}
|
||||
|
||||
@@ -1273,10 +1271,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
return allIds.length;
|
||||
}
|
||||
|
||||
async deleteEmail(emailId: string, accountId?: string): Promise<void> {
|
||||
async deleteEmail(emailId: string): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: accountId || this.accountId,
|
||||
accountId: this.accountId,
|
||||
destroy: [emailId],
|
||||
}, "0"],
|
||||
]);
|
||||
@@ -1445,20 +1443,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
async emptyMailbox(mailboxId: string): Promise<number> {
|
||||
let totalDestroyed = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
accountId: this.accountId,
|
||||
filter: { inMailbox: mailboxId },
|
||||
limit: 500,
|
||||
}, "0"],
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
accountId: this.accountId,
|
||||
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||
}, "1"],
|
||||
]);
|
||||
@@ -2152,18 +2149,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string,
|
||||
options?: { requestReadReceipt?: boolean }
|
||||
envelopeMailFrom?: string
|
||||
): Promise<SendEmailResult> {
|
||||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||
const emailId = `send-${Date.now()}`;
|
||||
const targetAccountId = (fromEmail && Object.keys(this.accounts).find(id =>
|
||||
this.accounts[id]?.name?.toLowerCase() === fromEmail.toLowerCase()
|
||||
)) || this.accountId;
|
||||
const mboxResp = await this.request([
|
||||
["Mailbox/get", { accountId: targetAccountId }, "0"]
|
||||
]);
|
||||
const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[];
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
if (!sentMailbox) {
|
||||
throw new Error('No sent mailbox found');
|
||||
@@ -2177,25 +2167,25 @@ export class JMAPClient implements IJMAPClient {
|
||||
let identityReplyTo: EmailAddress[] | undefined;
|
||||
{
|
||||
const identityResponse = await this.request([
|
||||
["Identity/get", { accountId: targetAccountId }, "0"]
|
||||
["Identity/get", { accountId: this.accountId }, "0"]
|
||||
]);
|
||||
|
||||
if (!finalIdentityId) {
|
||||
finalIdentityId = targetAccountId;
|
||||
finalIdentityId = this.accountId;
|
||||
}
|
||||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||
const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
|
||||
if (identities.length > 0) {
|
||||
let matchingIdentity = identityId
|
||||
? identities.find((id) => id.id === identityId)
|
||||
: undefined;
|
||||
if (!matchingIdentity) {
|
||||
if (!identityId) {
|
||||
const target = fromEmail || this.username;
|
||||
matchingIdentity = identities.find((id) => id.email === target)
|
||||
const matchingIdentity = identities.find((id) => id.email === target)
|
||||
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
||||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||||
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
|
||||
} else {
|
||||
const matchedIdentity = identities.find((id) => id.id === identityId);
|
||||
identityReplyTo = matchedIdentity?.replyTo;
|
||||
}
|
||||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||||
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2223,13 +2213,6 @@ export class JMAPClient implements IJMAPClient {
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
};
|
||||
|
||||
if (options?.requestReadReceipt) {
|
||||
// RFC 8098: ask the recipient's client to return a Message Disposition
|
||||
// Notification to our address. JMAP lets us set the raw header on create
|
||||
// via the "header:<Name>:asText" property form.
|
||||
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
|
||||
}
|
||||
|
||||
if (htmlBody) {
|
||||
// Send as multipart/alternative with both text and HTML
|
||||
emailCreate.bodyValues = {
|
||||
@@ -2295,21 +2278,21 @@ export class JMAPClient implements IJMAPClient {
|
||||
destroy: [draftId],
|
||||
}, "0"]);
|
||||
methodCalls.push(["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
accountId: this.accountId,
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "1"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.getSubmissionAccountId(targetAccountId),
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: buildSubmissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "2"]);
|
||||
} else {
|
||||
methodCalls.push(["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
accountId: this.accountId,
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.getSubmissionAccountId(targetAccountId),
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: buildSubmissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]);
|
||||
@@ -3018,125 +3001,21 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error('Invalid upload response: blobId not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
|
||||
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
|
||||
* where the exact MIME bytes must be preserved (Email/set can't express a
|
||||
* multipart/report report-type parameter reliably).
|
||||
*/
|
||||
async importEmail(
|
||||
blobId: string,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string
|
||||
): Promise<string | null> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const creationId = `imp-${Date.now()}`;
|
||||
const response = await this.request([
|
||||
["Email/import", {
|
||||
accountId: targetAccountId,
|
||||
emails: {
|
||||
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
const res = response.methodResponses?.[0];
|
||||
if (res?.[0] !== "Email/import") {
|
||||
console.error('Email/import: unexpected response', res);
|
||||
return null;
|
||||
}
|
||||
const payload = res[1] as {
|
||||
created?: Record<string, { id: string }>;
|
||||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||
};
|
||||
const created = payload?.created?.[creationId];
|
||||
if (!created) {
|
||||
const reason = payload?.notCreated?.[creationId];
|
||||
console.error('Email/import failed:', reason || payload);
|
||||
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
|
||||
* to a message that carried a Disposition-Notification-To header. Builds the
|
||||
* multipart/report, uploads it as a blob, imports it into Sent, then submits
|
||||
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
|
||||
* requesting address).
|
||||
*/
|
||||
async sendReadReceipt(params: {
|
||||
to: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
identityId: string;
|
||||
originalMessageId?: string | string[];
|
||||
originalSubject?: string;
|
||||
originalRecipient?: string;
|
||||
automatic?: boolean;
|
||||
accountId?: string;
|
||||
subject?: string;
|
||||
humanText?: string;
|
||||
}): Promise<void> {
|
||||
const targetAccountId = params.accountId || this.accountId;
|
||||
const { buildMdnMessage } = await import("@/lib/mdn");
|
||||
const raw = buildMdnMessage(params);
|
||||
|
||||
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
|
||||
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
|
||||
|
||||
const emailId = await this.importEmail(
|
||||
blobId,
|
||||
{ [targetMailbox.id]: true },
|
||||
{ "$seen": true },
|
||||
targetAccountId
|
||||
);
|
||||
if (!emailId) throw new Error('MDN import failed');
|
||||
|
||||
const subId = `mdnsub-${Date.now()}`;
|
||||
const response = await this.request([
|
||||
["EmailSubmission/set", {
|
||||
accountId: targetAccountId,
|
||||
create: {
|
||||
[subId]: {
|
||||
emailId,
|
||||
identityId: params.identityId,
|
||||
envelope: {
|
||||
mailFrom: { email: params.fromEmail },
|
||||
rcptTo: [{ email: params.to }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
const subRes = response.methodResponses?.[0];
|
||||
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
|
||||
if (notCreated) {
|
||||
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
if (!this.downloadUrl) {
|
||||
throw new Error('Download URL not available. Please reconnect.');
|
||||
}
|
||||
|
||||
// RFC 6570 level 1 URI template expansion. Blobs are scoped per account,
|
||||
// so a caller fetching a blob from a delegated/shared account must pass
|
||||
// that owner's accountId rather than defaulting to the primary one.
|
||||
// RFC 6570 level 1 URI template expansion
|
||||
return this.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(accountId || this.accountId))
|
||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name || 'download'))
|
||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||
}
|
||||
|
||||
async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||
async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await this.authenticatedFetch(url, {});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
@@ -5026,46 +4905,54 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||||
// FileNode/query omits folder nodes in Stalwart (see listAllFileNodes), so
|
||||
// we enumerate the whole account and filter children client-side.
|
||||
const all = await this.listAllFileNodes();
|
||||
return all.filter(n => (n.parentId ?? null) === parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every FileNode in the account, files AND folders, to build the folder
|
||||
* hierarchy client-side from parentId links.
|
||||
*
|
||||
* IMPORTANT: this uses `FileNode/get` with `ids: null` (return-all), NOT
|
||||
* `FileNode/query`. Stalwart's FileNode/query only returns leaf files - it
|
||||
* omits container (folder) nodes entirely - so a query-based listing made
|
||||
* every folder invisible (the whole account looked like a single root file).
|
||||
*/
|
||||
async listAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = this.getFilesAccountId();
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (parentId !== null) {
|
||||
filter.parentId = parentId;
|
||||
}
|
||||
// When parentId is null (root level), use empty filter to get all nodes.
|
||||
// Stalwart's FileNode/query does not support parentId: null as a filter value.
|
||||
|
||||
const response = await this.request(
|
||||
[["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]],
|
||||
[
|
||||
["FileNode/query", { accountId, filter }, "fnq0"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
|
||||
],
|
||||
this.fileUsing(),
|
||||
);
|
||||
|
||||
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0"));
|
||||
if (!getResult || getResult[0] === "error") {
|
||||
console.error('[Files] FileNode/get error:', getResult?.[1]);
|
||||
throw new Error(getResult?.[1]?.description || "FileNode list failed");
|
||||
// Check if query failed first
|
||||
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0"));
|
||||
if (queryResult && queryResult[0] === "error") {
|
||||
console.error('[Files] FileNode/query error:', queryResult[1]);
|
||||
throw new Error(queryResult[1]?.description || "FileNode/query failed");
|
||||
}
|
||||
return (getResult[1].list || []) as FileNode[];
|
||||
|
||||
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fnq0"));
|
||||
if (!getResult) {
|
||||
console.error('[Files] No FileNode/get response. Full response:', JSON.stringify(response.methodResponses));
|
||||
throw new Error("FileNode list failed - no response");
|
||||
}
|
||||
if (getResult[0] === "error") {
|
||||
console.error('[Files] FileNode/get error:', getResult[1]);
|
||||
throw new Error(getResult[1]?.description || "FileNode list failed");
|
||||
}
|
||||
const nodes = (getResult[1].list || []) as FileNode[];
|
||||
// When listing root, filter client-side to only show root-level items
|
||||
if (parentId === null) {
|
||||
return nodes.filter(n => n.parentId === null);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||
const accountId = this.getFilesAccountId();
|
||||
|
||||
// A FileNode is a folder (container) only when it has no content - i.e. no
|
||||
// blobId, type or size, so the server stores it with `file == null`. Sending
|
||||
// any of those - as older builds did (type "d" + an empty blob) - makes it a
|
||||
// 0-byte FILE that nothing can ever be parented under, which is what caused
|
||||
// "Parent ID does not exist or is not a folder" during the #379 migration.
|
||||
const dirProps: Record<string, unknown> = { name };
|
||||
// Stalwart requires a blobId even for directories - upload an empty blob
|
||||
const emptyBlob = new File([], name, { type: 'application/x-directory' });
|
||||
const { blobId } = await this.uploadBlob(emptyBlob);
|
||||
|
||||
const dirProps: Record<string, unknown> = { name, type: "d", blobId, size: 0 };
|
||||
if (parentId !== null) {
|
||||
dirProps.parentId = parentId;
|
||||
}
|
||||
@@ -5144,39 +5031,6 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update many FileNodes in a single FileNode/set call. Returns the ids that
|
||||
* were updated and a map of id -> error for any the server rejected. Never
|
||||
* throws for per-node failures (only for a whole-method error).
|
||||
*/
|
||||
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||
const ids = Object.keys(updates);
|
||||
if (ids.length === 0) return { updated: [], notUpdated: {} };
|
||||
const accountId = this.getFilesAccountId();
|
||||
|
||||
const response = await this.request(
|
||||
[["FileNode/set", { accountId, update: updates }, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||||
}
|
||||
|
||||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||||
const notUpdated: Record<string, string> = {};
|
||||
for (const id of Object.keys(notUpdatedMap)) {
|
||||
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||||
}
|
||||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||||
const updated = Object.keys(updatedMap).length > 0
|
||||
? Object.keys(updatedMap)
|
||||
: ids.filter(id => !(id in notUpdated));
|
||||
return { updated, notUpdated };
|
||||
}
|
||||
|
||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
const accountId = this.getFilesAccountId();
|
||||
|
||||
|
||||
+3
-19
@@ -13,21 +13,14 @@ export interface SieveCapabilities {
|
||||
externalLists: string[];
|
||||
}
|
||||
|
||||
export type FilterConditionField =
|
||||
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
||||
| 'attachment';
|
||||
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
|
||||
|
||||
export type FilterComparator =
|
||||
| 'contains' | 'not_contains'
|
||||
| 'is' | 'not_is'
|
||||
| 'starts_with' | 'ends_with'
|
||||
| 'matches'
|
||||
| 'greater_than' | 'less_than'
|
||||
// For field === 'attachment':
|
||||
// has_any → message has any attachment (Content-Disposition: attachment)
|
||||
// has_type → message has an attachment whose Content-Type matches `value`
|
||||
// (substring match, e.g. "application/pdf" or "image/")
|
||||
| 'has_any' | 'has_type';
|
||||
| 'greater_than' | 'less_than';
|
||||
|
||||
export type FilterActionType =
|
||||
| 'move' | 'copy' | 'forward'
|
||||
@@ -37,16 +30,7 @@ export type FilterActionType =
|
||||
export interface FilterCondition {
|
||||
field: FilterConditionField;
|
||||
comparator: FilterComparator;
|
||||
/**
|
||||
* Match value. Use a string array for OR-within-condition semantics
|
||||
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
|
||||
* Sieve emits the array as a list literal which the implementation
|
||||
* treats as "matches any item". Use a plain string for single-value
|
||||
* conditions; existing single-value rules continue to work unchanged.
|
||||
*
|
||||
* Not supported for: size (numeric), has_any (no value).
|
||||
*/
|
||||
value: string | string[];
|
||||
value: string;
|
||||
headerName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,16 +75,6 @@ export interface AuthenticationResults {
|
||||
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
/**
|
||||
* All SPF results when the server evaluated multiple identities (HELO and
|
||||
* MAIL FROM). Present only when more than one result was found; `result`
|
||||
* above is the most severe of these.
|
||||
*/
|
||||
all?: Array<{
|
||||
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
identity?: 'helo' | 'mailfrom';
|
||||
}>;
|
||||
};
|
||||
dkim?: {
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
@@ -498,10 +488,6 @@ export interface Calendar {
|
||||
// can route mutations to the right client. Distinct from `accountId`
|
||||
// which is the JMAP server's own account UUID.
|
||||
localAccountId?: string;
|
||||
// Set when `color` has been replaced by the viewer's local override for a
|
||||
// shared calendar (see lib/shared-calendar-colors). When true, the override
|
||||
// wins over per-event colors so the whole shared calendar paints uniformly.
|
||||
colorIsLocalOverride?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
|
||||
// message string. JMAP/Stalwart has no native MDN support, so the client
|
||||
// constructs the multipart/report itself and sends it via
|
||||
// blob-upload -> Email/import -> EmailSubmission/set (see client.sendReadReceipt).
|
||||
//
|
||||
// The message has two parts:
|
||||
// 1. text/plain — human-readable explanation (English, ASCII; rarely shown)
|
||||
// 2. message/disposition-notification — the machine-readable fields
|
||||
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
|
||||
// permits a two-part report.
|
||||
|
||||
export interface MdnOptions {
|
||||
/** Address that requested the receipt (Disposition-Notification-To) — the MDN recipient. */
|
||||
to: string;
|
||||
/** Our identity address (sender of the MDN). */
|
||||
fromEmail: string;
|
||||
/** Optional display name for the From header. */
|
||||
fromName?: string;
|
||||
/** Original Message-ID. JMAP may hand this back as a string[]
|
||||
* (header:Message-ID:asMessageIds), so accept both. */
|
||||
originalMessageId?: string | string[];
|
||||
/** Original Subject (used to build the MDN subject). */
|
||||
originalSubject?: string;
|
||||
/**
|
||||
* The address the original message was delivered to (our address/alias).
|
||||
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
|
||||
*/
|
||||
originalRecipient?: string;
|
||||
/** true => automatic-action (setting "always"); false => manual-action (user clicked send). */
|
||||
automatic?: boolean;
|
||||
/** Reporting-UA value, e.g. "mail.dornig.de; Bulwark Webmail". */
|
||||
reportingUa?: string;
|
||||
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
|
||||
subject?: string;
|
||||
/** Localized human-readable explanation (first report part). Defaults to English. */
|
||||
humanText?: string;
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
|
||||
function rfc5322Date(d: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
|
||||
}
|
||||
|
||||
/** UTF-8 string -> base64, without the deprecated unescape(). */
|
||||
function utf8ToBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
|
||||
function base64Body(text: string): string {
|
||||
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
|
||||
}
|
||||
|
||||
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
|
||||
function encodeHeaderWord(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!/[^\x00-\x7F]/.test(value)) return value;
|
||||
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
|
||||
}
|
||||
|
||||
function ensureAngles(messageId: string | string[] | undefined): string {
|
||||
// JMAP often returns Message-ID as a string[] (header:...:asMessageIds), so
|
||||
// normalize string | string[] | undefined down to a single bracketed id.
|
||||
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
|
||||
if (typeof raw !== "string") return "";
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const rnd = Math.random().toString(36).slice(2);
|
||||
return `${Date.now().toString(36)}.${rnd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
|
||||
* by the MIME standard so the bytes import/transmit verbatim.
|
||||
*/
|
||||
export function buildMdnMessage(opts: MdnOptions): string {
|
||||
const finalRecipient = opts.originalRecipient || opts.fromEmail;
|
||||
const domain = (opts.fromEmail.split("@")[1] || "localhost").trim();
|
||||
const messageId = `<mdn.${randomToken()}@${domain}>`;
|
||||
const boundary = `----=_MDN_${randomToken()}`;
|
||||
const origMsgId = ensureAngles(opts.originalMessageId); // normalized "<...>" or ""
|
||||
|
||||
const fromHeader = opts.fromName
|
||||
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
|
||||
: opts.fromEmail;
|
||||
|
||||
const subject = encodeHeaderWord(
|
||||
opts.subject ?? `Read: ${opts.originalSubject || ""}`.trim()
|
||||
);
|
||||
|
||||
const disposition = opts.automatic
|
||||
? "automatic-action/MDN-sent-automatically; displayed"
|
||||
: "manual-action/MDN-sent-manually; displayed";
|
||||
|
||||
const reportingUa = opts.reportingUa || `${domain}; Bulwark Webmail`;
|
||||
|
||||
// Human-readable part. Caller passes a localized humanText; fall back to
|
||||
// English. Encoded as UTF-8/base64 below so any language survives.
|
||||
const humanText = opts.humanText ?? [
|
||||
`This is a return receipt for the message you sent to ${finalRecipient}.`,
|
||||
``,
|
||||
`Note: This receipt only acknowledges that the message was displayed on the`,
|
||||
`recipient's computer. There is no guarantee that the recipient has read or`,
|
||||
`understood the message contents.`,
|
||||
].join("\r\n");
|
||||
|
||||
// Machine-readable disposition-notification part (pure ASCII tokens).
|
||||
const mdnFields = [
|
||||
`Reporting-UA: ${reportingUa}`,
|
||||
`Final-Recipient: rfc822;${finalRecipient}`,
|
||||
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
|
||||
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
|
||||
`Disposition: ${disposition}`,
|
||||
].join("\r\n");
|
||||
|
||||
return [
|
||||
`Date: ${rfc5322Date()}`,
|
||||
`From: ${fromHeader}`,
|
||||
`To: ${opts.to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/report; report-type=disposition-notification;`,
|
||||
`\tboundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
`Content-Transfer-Encoding: base64`,
|
||||
``,
|
||||
base64Body(humanText),
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: message/disposition-notification`,
|
||||
`Content-Transfer-Encoding: 7bit`,
|
||||
``,
|
||||
mdnFields,
|
||||
``,
|
||||
`--${boundary}--`,
|
||||
``,
|
||||
].join("\r\n");
|
||||
}
|
||||
@@ -19,12 +19,6 @@ export function getDiscoveryValidator(): EndpointValidator | undefined {
|
||||
function getGlobalClientSecret(): string {
|
||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||
if (adminSecret) return adminSecret;
|
||||
|
||||
const adminFileSecret = readFileEnv(
|
||||
configManager.get<string>('oauthClientSecretFile', ''),
|
||||
);
|
||||
if (adminFileSecret) return adminFileSecret;
|
||||
|
||||
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||
}
|
||||
|
||||
|
||||
+19
-12
@@ -10,25 +10,32 @@ import {
|
||||
activateAllSandboxed,
|
||||
deactivateAllSandboxed,
|
||||
setSandboxStoreAccessor,
|
||||
setSandboxLocale,
|
||||
setupSandboxAutoDisable,
|
||||
} from './plugin-sandbox/loader';
|
||||
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||
|
||||
// Re-export so the plugin store can keep the sandbox locale in step via this
|
||||
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
|
||||
// also pull the hook buses into consumers' module graphs).
|
||||
export { setSandboxLocale } from './plugin-sandbox/loader';
|
||||
|
||||
/**
|
||||
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
|
||||
* loader, and later also bootstrapped plugin locale sync. Both are obsolete:
|
||||
* the sandbox injects React per-iframe, and locale sync now lives where plugin
|
||||
* activation is orchestrated (stores/plugin-store -> initializePlugins, via
|
||||
* setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins()
|
||||
* wrapper and its test.
|
||||
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
|
||||
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
||||
* plugins receive React injected as a function argument inside their iframe
|
||||
* runtime - there is nothing to expose on the host window.
|
||||
*
|
||||
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
||||
*/
|
||||
export function exposePluginExternals(): void {
|
||||
/* no-op */
|
||||
if (typeof window === 'undefined') return;
|
||||
// Initialise the locale sync once. Importing the store lazily avoids the
|
||||
// circular module graph we used to fight before the sandbox refactor.
|
||||
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
||||
setSandboxLocale(useLocaleStore.getState().locale);
|
||||
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
|
||||
// Mirror on a global so the slot-iframe component can read it at spawn.
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
|
||||
useLocaleStore.subscribe((state) => {
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
|
||||
});
|
||||
}).catch(() => { /* locale sync is best-effort */ });
|
||||
}
|
||||
|
||||
// ─── Store accessor (status updates) ──────────────────────────
|
||||
|
||||
@@ -5,40 +5,6 @@
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import type { EmailReadView } from '@/lib/plugin-types';
|
||||
|
||||
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
|
||||
// translate or scan content need the real body, not just the short `preview`
|
||||
// snippet. Prefers text/plain parts; falls back to stripped HTML, then preview.
|
||||
function plainTextFromEmail(email: Email): string {
|
||||
const values = email.bodyValues || {};
|
||||
const collect = (parts?: { partId: string }[]) =>
|
||||
(parts || [])
|
||||
.map((p) => values[p.partId]?.value)
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const text = collect(email.textBody);
|
||||
if (text) return text;
|
||||
|
||||
const html = collect(email.htmlBody);
|
||||
if (html) {
|
||||
return html
|
||||
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/'|'/gi, "'")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return (email.preview || '').trim();
|
||||
}
|
||||
|
||||
export function emailToReadView(email: Email): EmailReadView {
|
||||
return {
|
||||
id: email.id,
|
||||
@@ -53,7 +19,6 @@ export function emailToReadView(email: Email): EmailReadView {
|
||||
isFlagged: !!email.keywords?.['$flagged'],
|
||||
hasAttachment: email.hasAttachment,
|
||||
preview: email.preview || '',
|
||||
text: plainTextFromEmail(email),
|
||||
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
|
||||
auth: email.authenticationResults,
|
||||
};
|
||||
|
||||
@@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
|
||||
headers['Authorization'] = client.getAuthHeader();
|
||||
headers['X-JMAP-Username'] = client.getUsername();
|
||||
}
|
||||
const res = await apiFetch(url.pathname + url.search, {
|
||||
const res = await fetch(url.pathname + url.search, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
import type { InstalledPlugin, SlotName } from '../plugin-types';
|
||||
import { dispatchApiCall } from './host-api';
|
||||
import { SANDBOX_PATH } from './protocol';
|
||||
import { withBasePath } from '../browser-navigation';
|
||||
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||
import type {
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||
} from './protocol';
|
||||
@@ -145,10 +143,7 @@ export class SandboxInstance {
|
||||
this.iframe.style.width = '100%';
|
||||
this.iframe.style.height = '0px';
|
||||
}
|
||||
// Prefix with the mount path so the sandbox route resolves under a
|
||||
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
|
||||
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
|
||||
this.iframe.src = withBasePath(SANDBOX_PATH);
|
||||
this.iframe.src = SANDBOX_PATH;
|
||||
|
||||
this.listener = (ev) => this.onMessage(ev);
|
||||
window.addEventListener('message', this.listener);
|
||||
@@ -279,12 +274,6 @@ export class SandboxInstance {
|
||||
this.send({ type: 'locale-change', locale });
|
||||
}
|
||||
|
||||
/** Push a new resolved theme so the slot iframe re-injects its theme CSS. */
|
||||
setTheme(theme: ThemeSnapshot): void {
|
||||
if (this.destroyed) return;
|
||||
this.send({ type: 'theme-change', theme });
|
||||
}
|
||||
|
||||
updateProps(props: Record<string, unknown>): void {
|
||||
if (this.destroyed) return;
|
||||
// Stale references would leak if we kept growing the table without
|
||||
@@ -353,7 +342,6 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
||||
},
|
||||
extraProps: opts.extraProps,
|
||||
locale: opts.locale,
|
||||
theme: snapshotHostTheme(),
|
||||
};
|
||||
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Theme bridge for plugin slot iframes.
|
||||
//
|
||||
// Slot iframes run with an opaque ("null") origin, so they can't load the
|
||||
// host's globals.css or web fonts cross-origin (see app/(sandbox)/layout.tsx).
|
||||
// The result: plugin slot UIs fall back to the UA default serif font and have
|
||||
// no knowledge of the host's light/dark (or custom) theme.
|
||||
//
|
||||
// To fix that without any cross-origin asset fetch, the host snapshots the
|
||||
// *resolved* theme — the computed `--color-*` token values, the resolved
|
||||
// font-family (which is a pure system-font stack, so no fetch is needed), and
|
||||
// whether dark mode is active — and ships it across the postMessage bridge.
|
||||
// The sandbox runtime replays the snapshot as an injected <style> block plus a
|
||||
// `.dark` class, so every plugin slot inherits the app font and can react to
|
||||
// theme changes by reading `var(--color-*)`.
|
||||
|
||||
/** A replayable description of the host's currently resolved theme. */
|
||||
export interface ThemeSnapshot {
|
||||
/** Whether the host has dark mode active (`.dark` on <html>). */
|
||||
dark: boolean;
|
||||
/** Resolved font-family stack (system fonts only — safe to replay verbatim). */
|
||||
fontFamily: string;
|
||||
/** Resolved values for each mirrored CSS custom property. */
|
||||
vars: Record<string, string>;
|
||||
}
|
||||
|
||||
// CSS custom properties mirrored into plugin slots. Kept in sync with the
|
||||
// `:root` token block in app/globals.css. Both the colour tokens (the stable
|
||||
// theming API surface) and the tier-2 typography/density tokens are included so
|
||||
// plugins can match the host's metrics, not just its colours.
|
||||
const THEME_TOKENS: readonly string[] = [
|
||||
'--color-border',
|
||||
'--color-input',
|
||||
'--color-ring',
|
||||
'--color-background',
|
||||
'--color-foreground',
|
||||
'--color-primary',
|
||||
'--color-primary-foreground',
|
||||
'--color-secondary',
|
||||
'--color-secondary-foreground',
|
||||
'--color-muted',
|
||||
'--color-muted-foreground',
|
||||
'--color-accent',
|
||||
'--color-accent-foreground',
|
||||
'--color-destructive',
|
||||
'--color-destructive-foreground',
|
||||
'--color-popover',
|
||||
'--color-popover-foreground',
|
||||
'--color-sidebar',
|
||||
'--color-sidebar-foreground',
|
||||
'--color-sidebar-border',
|
||||
'--color-sidebar-accent',
|
||||
'--color-sidebar-accent-foreground',
|
||||
'--color-card',
|
||||
'--color-card-foreground',
|
||||
'--color-success',
|
||||
'--color-success-foreground',
|
||||
'--color-warning',
|
||||
'--color-warning-foreground',
|
||||
'--color-info',
|
||||
'--color-info-foreground',
|
||||
'--color-selection',
|
||||
'--color-selection-foreground',
|
||||
'--color-unread',
|
||||
'--color-chart-1',
|
||||
'--color-chart-2',
|
||||
'--color-chart-3',
|
||||
'--color-chart-4',
|
||||
'--color-chart-5',
|
||||
'--font-size-base',
|
||||
'--list-item-height',
|
||||
'--transition-duration',
|
||||
'--density-item-py',
|
||||
'--density-item-gap',
|
||||
'--density-header-py',
|
||||
'--density-card-p',
|
||||
'--density-sidebar-py',
|
||||
];
|
||||
|
||||
// Mirrors the body font stack in app/globals.css. Used when no document is
|
||||
// available (SSR) or the body has no resolvable font-family yet.
|
||||
const FALLBACK_FONT_FAMILY =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Thai", "Leelawadee UI", Tahoma, sans-serif';
|
||||
|
||||
/**
|
||||
* Read the host's resolved theme off `<html>`. Reading computed styles means
|
||||
* the snapshot automatically reflects the active built-in *and* custom theme,
|
||||
* not just the static globals.css defaults.
|
||||
*/
|
||||
export function snapshotHostTheme(): ThemeSnapshot {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return { dark: false, fontFamily: FALLBACK_FONT_FAMILY, vars: {} };
|
||||
}
|
||||
const root = document.documentElement;
|
||||
const computed = getComputedStyle(root);
|
||||
const vars: Record<string, string> = {};
|
||||
for (const token of THEME_TOKENS) {
|
||||
const value = computed.getPropertyValue(token).trim();
|
||||
if (value) vars[token] = value;
|
||||
}
|
||||
const bodyFont = document.body ? getComputedStyle(document.body).fontFamily : '';
|
||||
return {
|
||||
dark: root.classList.contains('dark'),
|
||||
fontFamily: bodyFont || FALLBACK_FONT_FAMILY,
|
||||
vars,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CSS the sandbox injects to replay a snapshot. Pure (no DOM access)
|
||||
* so it can run inside the iframe runtime. The `:root` block restores the
|
||||
* host's token values; the `html, body` block gives plugin slots the app font
|
||||
* and a theme-aware default text colour with a transparent background (the
|
||||
* host's themed container shows through).
|
||||
*/
|
||||
export function themeSnapshotToCSS(snapshot: ThemeSnapshot): string {
|
||||
const declarations = Object.entries(snapshot.vars)
|
||||
.map(([name, value]) => ` ${name}: ${value};`)
|
||||
.join('\n');
|
||||
const colorScheme = snapshot.dark ? 'dark' : 'light';
|
||||
return [
|
||||
':root {',
|
||||
declarations,
|
||||
` color-scheme: ${colorScheme};`,
|
||||
'}',
|
||||
'html, body {',
|
||||
` font-family: ${snapshot.fontFamily};`,
|
||||
' color: var(--color-foreground, inherit);',
|
||||
' background: transparent;',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -41,16 +41,11 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
|
||||
|
||||
let currentLocale = 'en';
|
||||
export function setSandboxLocale(locale: string): void {
|
||||
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
|
||||
// good locale back to '' - the initial 'en' default stands until the real
|
||||
// locale arrives via the store subscription.
|
||||
if (!locale) return;
|
||||
currentLocale = locale;
|
||||
// Background instances read `currentLocale` at load time; the slot-iframe
|
||||
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
|
||||
// both in step from one place. Already-running instances are not re-pushed,
|
||||
// so a locale switch only affects plugins/slots loaded afterwards.
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
|
||||
// Push to all active background instances.
|
||||
// Slot iframes inherit locale at spawn time; they're short-lived.
|
||||
// (We don't import the registry here to avoid a circular import; the
|
||||
// PluginIframeSlot subscribes to locale changes on its own.)
|
||||
}
|
||||
|
||||
// ─── Bundle fetch ─────────────────────────────────────────────
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// class instances.
|
||||
|
||||
import type { SlotName } from '../plugin-types';
|
||||
import type { ThemeSnapshot } from './host-theme';
|
||||
|
||||
// ─── Sandbox mode ────────────────────────────────────────────
|
||||
|
||||
@@ -63,12 +62,6 @@ export interface SlotInit {
|
||||
*/
|
||||
extraProps: Record<string, unknown>;
|
||||
locale: string;
|
||||
/**
|
||||
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
|
||||
* can't load globals.css/fonts cross-origin, so the runtime replays this as
|
||||
* injected CSS + a `.dark` class. Host pushes updates via 'theme-change'.
|
||||
*/
|
||||
theme: ThemeSnapshot;
|
||||
}
|
||||
|
||||
export type InitPayload = BackgroundInit | SlotInit;
|
||||
@@ -170,9 +163,6 @@ export interface HookInvokeMsg {
|
||||
|
||||
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
|
||||
|
||||
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
|
||||
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
|
||||
|
||||
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
|
||||
|
||||
export interface SlotShouldShowMsg {
|
||||
@@ -188,7 +178,6 @@ export type HostToSandbox =
|
||||
| CallbackResponseMsg
|
||||
| HookInvokeMsg
|
||||
| LocaleChangeMsg
|
||||
| ThemeChangeMsg
|
||||
| PropsUpdateMsg
|
||||
| SlotShouldShowMsg;
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
BackgroundInit,
|
||||
SlotInit,
|
||||
} from './protocol';
|
||||
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
|
||||
// ─── Module-scope state ──────────────────────────────────────
|
||||
@@ -68,54 +67,24 @@ function sendToHost(msg: SandboxToHost): void {
|
||||
parentWindow.postMessage(msg, parentOrigin);
|
||||
}
|
||||
|
||||
// ─── Theme replay ────────────────────────────────────────────
|
||||
|
||||
const THEME_STYLE_ID = '__plugin_host_theme';
|
||||
|
||||
/**
|
||||
* Replay a host theme snapshot inside the iframe: inject the token + font CSS
|
||||
* and mirror the `.dark` class onto <html> so plugin styles that key off
|
||||
* `.dark` (or read `var(--color-*)`) behave like the host. Idempotent — safe
|
||||
* to call again on every 'theme-change'.
|
||||
*/
|
||||
function applyHostTheme(theme: ThemeSnapshot): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = THEME_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = themeSnapshotToCSS(theme);
|
||||
document.documentElement.classList.toggle('dark', theme.dark);
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
||||
|
||||
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
||||
|
||||
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
|
||||
function callApi(method: string, args: unknown[]): Promise<unknown> {
|
||||
const id = uid();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingApi.set(id, { resolve, reject });
|
||||
sendToHost({ type: 'api-request', id, method, args });
|
||||
// Bounded so a hung host can't leak the promise forever. Interactive UI
|
||||
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
|
||||
// for human input, the host always resolves them on confirm/cancel/close,
|
||||
// and any still-pending call dies with the iframe on teardown - so there's
|
||||
// nothing to leak, and a thinking user must not trip a 30s timeout.
|
||||
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
|
||||
setTimeout(() => {
|
||||
const entry = pendingApi.get(id);
|
||||
if (!entry) return;
|
||||
pendingApi.delete(id);
|
||||
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
|
||||
setTimeout(() => {
|
||||
const entry = pendingApi.get(id);
|
||||
if (!entry) return;
|
||||
pendingApi.delete(id);
|
||||
entry.reject(new Error(`API call ${method} timed out after 30s`));
|
||||
}, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,13 +151,12 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
||||
},
|
||||
ui: {
|
||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
|
||||
* No timeout - it waits for the user's choice. */
|
||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
|
||||
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
||||
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
||||
callApi('ui.confirm', [opts]) as Promise<boolean>,
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
|
||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||
callApi('ui.alert', [opts], 0) as Promise<void>,
|
||||
callApi('ui.alert', [opts]) as Promise<void>,
|
||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||
openExternalUrl: (url: string, target?: string) =>
|
||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||
@@ -205,26 +173,6 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
||||
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
|
||||
},
|
||||
// Localization for plugins. The host pushes the active locale (init +
|
||||
// 'locale-change'); `t` resolves a key against the plugin's declared
|
||||
// `locales` map (manifest.locales), falling back to English then the key
|
||||
// itself, with optional {placeholder} interpolation.
|
||||
i18n: {
|
||||
get locale(): string {
|
||||
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
},
|
||||
t(key: string, vars?: Record<string, string | number>): string {
|
||||
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
const tables = manifest.locales || {};
|
||||
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
out = out.split('{' + k + '}').join(String(v));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,10 +281,6 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
}
|
||||
|
||||
function bootSlot(payload: SlotInit): void {
|
||||
// Replay the host theme before first paint so the slot never flashes the UA
|
||||
// default serif font or a light-on-light/dark mismatch.
|
||||
applyHostTheme(payload.theme);
|
||||
|
||||
const api = buildPluginApi(payload.manifest);
|
||||
const exports = evaluateBundle(payload.code, api);
|
||||
pluginExports = exports;
|
||||
@@ -400,9 +344,6 @@ async function handleInit(payload: InitPayload): Promise<void> {
|
||||
if (bootDone) return;
|
||||
bootDone = true;
|
||||
mode = payload.mode;
|
||||
// Make the active locale available to plugin code (api.i18n) right away -
|
||||
// not only after the first 'locale-change' push.
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
|
||||
try {
|
||||
if (payload.mode === 'background') {
|
||||
await bootBackground(payload);
|
||||
@@ -489,10 +430,6 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
||||
break;
|
||||
|
||||
case 'theme-change':
|
||||
applyHostTheme(msg.theme);
|
||||
break;
|
||||
|
||||
case 'props-update':
|
||||
slotPropsUpdater?.(msg.props ?? {});
|
||||
break;
|
||||
|
||||
@@ -374,10 +374,6 @@ export interface EmailReadView {
|
||||
hasAttachment: boolean;
|
||||
preview: string;
|
||||
keywords: string[];
|
||||
/** Full plain-text body of the message (HTML-only bodies are stripped to
|
||||
* text). Empty string when the host hasn't loaded the body. Same
|
||||
* `email:read` sensitivity as the rest of this view. */
|
||||
text: string;
|
||||
/**
|
||||
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
|
||||
* Absent on stores that didn't parse the header (e.g. bodies not yet
|
||||
|
||||
+4
-38
@@ -10,17 +10,6 @@ import { formatDateTime } from "@/lib/utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
|
||||
|
||||
// Localized label set the caller passes in. Labels live on the client where
|
||||
// useTranslations is available; this module stays framework-agnostic.
|
||||
export interface QuoteHeaderLabels {
|
||||
/** ICU-formatted reply line, e.g. "On {date}, {from} wrote:" with placeholders already substituted. */
|
||||
formatReplyLine: (vars: { date: string; from: string }) => string;
|
||||
forwardedSeparator: string;
|
||||
fromLabel: string;
|
||||
dateLabel: string;
|
||||
subjectLabel: string;
|
||||
}
|
||||
|
||||
interface BuildArgs {
|
||||
mode: "reply" | "replyAll" | "forward";
|
||||
email: {
|
||||
@@ -35,24 +24,10 @@ interface BuildArgs {
|
||||
locale: string;
|
||||
timeFormat: "12h" | "24h";
|
||||
unknownLabel: string;
|
||||
/**
|
||||
* Localized labels. Optional for backward compatibility; falls back to
|
||||
* English (matching the original hardcoded behaviour) when not supplied.
|
||||
*/
|
||||
labels?: QuoteHeaderLabels;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: QuoteHeaderLabels = {
|
||||
formatReplyLine: ({ date, from }) => `On ${date}, ${from} wrote:`,
|
||||
forwardedSeparator: "---------- Forwarded message ----------",
|
||||
fromLabel: "From",
|
||||
dateLabel: "Date",
|
||||
subjectLabel: "Subject",
|
||||
};
|
||||
|
||||
function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
const { mode, email, timeFormat, unknownLabel } = args;
|
||||
const labels = args.labels ?? DEFAULT_LABELS;
|
||||
const date = email.receivedAt
|
||||
? formatDateTime(email.receivedAt, timeFormat, {
|
||||
weekday: "short",
|
||||
@@ -63,25 +38,16 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
: "";
|
||||
const from = email.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : unknownLabel;
|
||||
// Forward header "From:" shows the full sender incl. address ("Name
|
||||
// <email>"), like every mail client. The reply line keeps the bare name
|
||||
// (reads more naturally in "On … wrote:").
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || unknownLabel))
|
||||
: unknownLabel;
|
||||
const subject = email.subject || "";
|
||||
|
||||
if (mode === "forward") {
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${fromStrFull}<br>${labels.dateLabel}: ${date}<br>${labels.subjectLabel}: ${subject}<br><br></div>`;
|
||||
const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`;
|
||||
const html = `<div>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${subject}<br><br></div>`;
|
||||
return { html, text, wrapInBlockquote: false };
|
||||
}
|
||||
|
||||
const replyLine = labels.formatReplyLine({ date, from: fromStr });
|
||||
const text = `${replyLine}\n`;
|
||||
const html = `<div>${replyLine}<br></div>`;
|
||||
const text = `On ${date}, ${fromStr} wrote:\n`;
|
||||
const html = `<div>On ${date}, ${fromStr} wrote:<br></div>`;
|
||||
return { html, text, wrapInBlockquote: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
|
||||
/**
|
||||
* Palette of calendar colors offered in the color picker. Defined here (rather
|
||||
* than in the settings UI component) so non-React modules can reuse it without
|
||||
* pulling in component code. The settings color picker re-exports this.
|
||||
*/
|
||||
export 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
|
||||
];
|
||||
|
||||
/**
|
||||
* Stable key for a shared calendar's local color override. Independent of the
|
||||
* Pro-shell id prefix (which changes with the active account), so the override
|
||||
* survives shell-mode and account switches. Built from the owning JMAP account
|
||||
* + the calendar's original server id.
|
||||
*/
|
||||
export function sharedCalendarColorKey(
|
||||
cal: Pick<Calendar, 'id' | 'originalId' | 'accountId' | 'localAccountId'>,
|
||||
): string {
|
||||
const localAccount = cal.localAccountId ?? '';
|
||||
const account = cal.accountId ?? '';
|
||||
const id = cal.originalId ?? cal.id;
|
||||
return `${localAccount}|${account}|${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random palette color not present in `usedColors`. Once every palette
|
||||
* entry is taken, fall back to a random palette color (collisions are
|
||||
* unavoidable past CALENDAR_COLORS.length calendars).
|
||||
*/
|
||||
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
|
||||
const used = new Set<string>();
|
||||
for (const c of usedColors) {
|
||||
if (c) used.add(c.toLowerCase());
|
||||
}
|
||||
const available = CALENDAR_COLORS.filter((c) => !used.has(c.toLowerCase()));
|
||||
const pool = available.length > 0 ? available : CALENDAR_COLORS;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
+12
-53
@@ -12,82 +12,42 @@ function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Normalise the condition value to a non-empty string array. Single-value
|
||||
// conditions stay one-element; arrays are filtered for empty strings.
|
||||
function toValueList(value: string | string[]): string[] {
|
||||
const arr = Array.isArray(value) ? value : [value];
|
||||
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
|
||||
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
|
||||
// the condition); the single-string form is emitted unchanged when len === 1
|
||||
// so existing scripts and tests stay byte-identical.
|
||||
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
|
||||
if (values.length === 1) {
|
||||
return `"${escapeString(transform(values[0]))}"`;
|
||||
}
|
||||
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
|
||||
}
|
||||
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
if (field === 'size') {
|
||||
// Size is numeric, single value only.
|
||||
const sizeValue = Array.isArray(value) ? value[0] : value;
|
||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||
return `size ${op} ${sizeValue}`;
|
||||
return `size ${op} ${value}`;
|
||||
}
|
||||
|
||||
const values = toValueList(value);
|
||||
|
||||
if (field === 'body') {
|
||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
||||
return `body ${matchType} ${formatStringArg(values)}`;
|
||||
}
|
||||
|
||||
if (field === 'attachment') {
|
||||
// RFC 5703: :mime :anychild matches against headers of any MIME part.
|
||||
// has_any tests Content-Disposition for "attachment"; has_type matches
|
||||
// the file extension against the filename across BOTH Content-Disposition
|
||||
// (filename= parameter) and Content-Type (name= parameter) - many older
|
||||
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
|
||||
// in Content-Type and leave Content-Disposition without a filename.
|
||||
// RFC 5228 §5.7 allows a string-list for header names; the test passes
|
||||
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
|
||||
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
|
||||
// a literal substring in all of them.
|
||||
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
|
||||
// = OR within the condition (any item matches → test passes).
|
||||
if (comparator === 'has_any') {
|
||||
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
|
||||
}
|
||||
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
|
||||
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
|
||||
return `body ${matchType} "${escapeString(value)}"`;
|
||||
}
|
||||
|
||||
const headerName = field === 'header'
|
||||
? (condition.headerName || 'X-Unknown')
|
||||
: HEADER_MAP[field];
|
||||
|
||||
const escaped = escapeString(value);
|
||||
|
||||
switch (comparator) {
|
||||
case 'contains':
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
case 'not_contains':
|
||||
return `not header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
return `not header :contains "${headerName}" "${escaped}"`;
|
||||
case 'is':
|
||||
return `header :is "${headerName}" ${formatStringArg(values)}`;
|
||||
return `header :is "${headerName}" "${escaped}"`;
|
||||
case 'not_is':
|
||||
return `not header :is "${headerName}" ${formatStringArg(values)}`;
|
||||
return `not header :is "${headerName}" "${escaped}"`;
|
||||
case 'starts_with':
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
|
||||
return `header :matches "${headerName}" "${escaped}*"`;
|
||||
case 'ends_with':
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
|
||||
return `header :matches "${headerName}" "*${escaped}"`;
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||
return `header :matches "${headerName}" "${escaped}"`;
|
||||
default:
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +89,6 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
|
||||
for (const rule of enabledRules) {
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
if (condition.field === 'attachment') extensions.add('mime');
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
switch (action.type) {
|
||||
|
||||
+22
-133
@@ -36,11 +36,7 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||
function isValidCondition(c: unknown): boolean {
|
||||
if (!c || typeof c !== 'object') return false;
|
||||
const cond = c as Record<string, unknown>;
|
||||
if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
|
||||
// value may be a string OR a non-empty array of strings (Patch 11
|
||||
// multi-value semantics). Accept both.
|
||||
if (typeof cond.value === 'string') return true;
|
||||
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
|
||||
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
|
||||
}
|
||||
|
||||
function isValidAction(a: unknown): boolean {
|
||||
@@ -338,150 +334,43 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the value-tail of a header/body test: either a single quoted
|
||||
// string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped
|
||||
// value(s), preserving the array shape when present so the caller can
|
||||
// detect multi-value conditions.
|
||||
const parseValueTail = (raw: string): string | string[] | null => {
|
||||
const trimmed = raw.trim();
|
||||
// List form
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const inner = trimmed.slice(1, -1);
|
||||
const items: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let mm: RegExpExecArray | null;
|
||||
let cursor = 0;
|
||||
while ((mm = re.exec(inner)) !== null) {
|
||||
// Ensure only whitespace and commas appear between items
|
||||
if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null;
|
||||
items.push(unescapeSieveString(mm[1]));
|
||||
cursor = mm.index + mm[0].length;
|
||||
}
|
||||
if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null;
|
||||
if (items.length === 0) return null;
|
||||
return items.length === 1 ? items[0] : items;
|
||||
}
|
||||
// Single string form
|
||||
const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
|
||||
if (single) return unescapeSieveString(single[1]);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Classify a :matches value (or values) into starts_with / ends_with /
|
||||
// matches by inspecting wildcard positions. For multi-value, all items
|
||||
// must share the same shape; otherwise we fall back to 'matches' and
|
||||
// keep the wildcards verbatim.
|
||||
const classifyMatches = (
|
||||
values: string | string[],
|
||||
): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => {
|
||||
const arr = Array.isArray(values) ? values : [values];
|
||||
const isTrailing = (v: string) => {
|
||||
const stars = [...v].filter((c) => c === '*').length;
|
||||
return stars === 1 && v.endsWith('*');
|
||||
};
|
||||
const isLeading = (v: string) => {
|
||||
const stars = [...v].filter((c) => c === '*').length;
|
||||
return stars === 1 && v.startsWith('*');
|
||||
};
|
||||
if (arr.every(isTrailing)) {
|
||||
const stripped = arr.map((v) => v.slice(0, -1));
|
||||
return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
|
||||
}
|
||||
if (arr.every(isLeading)) {
|
||||
const stripped = arr.map((v) => v.slice(1));
|
||||
return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
|
||||
}
|
||||
return { comparator: 'matches', stripped: values };
|
||||
};
|
||||
|
||||
// Match attachment-aware :mime :anychild tests before the generic header
|
||||
// pattern - emitted by our own generator for field === 'attachment'.
|
||||
// has_any: ":contains Content-Disposition attachment"
|
||||
let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s);
|
||||
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'attachment', comparator: 'has_any', value: '' };
|
||||
}
|
||||
// has_type: ":matches <headers> <value-tail>"
|
||||
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
|
||||
// to catch senders who put the filename only in Content-Type's name= param
|
||||
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
|
||||
// - Legacy emit form used a single "Content-Disposition" header - still
|
||||
// recognised here so rules saved before the fix remain editable.
|
||||
// Each value item must be a "*.<ext>*" wildcard pattern.
|
||||
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
|
||||
// Header part: accept either a single quoted string or a 2-element list
|
||||
// containing exactly Content-Disposition + Content-Type (in any order).
|
||||
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
|
||||
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
|
||||
let headersOk = false;
|
||||
if (single) {
|
||||
headersOk = true;
|
||||
} else if (listForm) {
|
||||
const inner = listForm[1];
|
||||
const items: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
|
||||
const expected = new Set(['Content-Disposition', 'Content-Type']);
|
||||
const got = new Set(items);
|
||||
headersOk =
|
||||
items.length === expected.size &&
|
||||
[...expected].every((h) => got.has(h));
|
||||
}
|
||||
if (!headersOk) return null;
|
||||
const tail = parseValueTail(rawValue);
|
||||
if (tail === null) return null;
|
||||
const arr = Array.isArray(tail) ? tail : [tail];
|
||||
const exts: string[] = [];
|
||||
for (const item of arr) {
|
||||
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
|
||||
if (!em) return null;
|
||||
exts.push(unescapeSieveString(em[1]));
|
||||
}
|
||||
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
|
||||
};
|
||||
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const result = tryHasType(m[1], m[2]);
|
||||
if (result) return result;
|
||||
}
|
||||
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
|
||||
// opaque rendering so we don't silently misrepresent the script.
|
||||
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawTail] = m;
|
||||
const value = parseValueTail(rawTail);
|
||||
if (value === null) return null;
|
||||
const [, tag, headerName, rawValue] = m;
|
||||
const value = unescapeSieveString(rawValue);
|
||||
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||
|
||||
let comparator: FilterComparator;
|
||||
let finalValue: string | string[];
|
||||
if (tag === 'contains') {
|
||||
comparator = negated ? 'not_contains' : 'contains';
|
||||
finalValue = value;
|
||||
} else if (tag === 'is') {
|
||||
comparator = negated ? 'not_is' : 'is';
|
||||
finalValue = value;
|
||||
} else {
|
||||
const classified = classifyMatches(value);
|
||||
comparator = classified.comparator;
|
||||
finalValue = classified.stripped;
|
||||
// :matches - distinguish starts_with / ends_with / matches
|
||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||
comparator = 'starts_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
if (starPositions.length === 1 && starPositions[0] === 0) {
|
||||
comparator = 'ends_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
comparator = 'matches';
|
||||
}
|
||||
|
||||
const cond: FilterCondition = { field, comparator, value: finalValue };
|
||||
const cond: FilterCondition = { field, comparator, value };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
|
||||
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
|
||||
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
const value = parseValueTail(m[2]);
|
||||
if (value === null) return null;
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
|
||||
}
|
||||
|
||||
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||
|
||||
@@ -124,35 +124,6 @@ export function appendPlainTextSignature(
|
||||
return `${body}${sep}${plainTextSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a signature to an HTML body, preserving rich formatting. Used by the
|
||||
* quick-reply path so an HTML signature keeps its markup instead of being
|
||||
* flattened to plain text. Mirrors the composer's send-time signature block
|
||||
* (`buildSignatureHtml` in email-composer.tsx).
|
||||
*/
|
||||
export function appendHtmlSignature(
|
||||
htmlBody: string,
|
||||
signature?: SignatureSource | null,
|
||||
options: { separator?: boolean } = {},
|
||||
): string {
|
||||
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
|
||||
|
||||
if (signature?.htmlSignature?.trim()) {
|
||||
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
|
||||
}
|
||||
|
||||
if (signature?.textSignature?.trim()) {
|
||||
const escaped = signature.textSignature
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${htmlBody}${sep}${escaped}`;
|
||||
}
|
||||
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||
if (!html.trim()) return false;
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Reply / forward subject prefix handling.
|
||||
//
|
||||
// Real-world email subjects accumulate prefixes across clients and languages:
|
||||
// "Re: AW: WG: Fwd: Re: foo". The deduplication regex needs to know ALL
|
||||
// commonly-used reply/forward markers - not just the current locale's, since
|
||||
// inbound messages may come from any locale. Failing to strip a foreign-locale
|
||||
// prefix means the user's locale prefix gets *added on top* and the subject
|
||||
// chain keeps growing.
|
||||
//
|
||||
// Sources: de-facto conventions in Outlook / Thunderbird / Apple Mail per
|
||||
// language. Includes a handful of legacy short-forms (R:, Fw:) that some
|
||||
// mobile clients still emit.
|
||||
|
||||
const REPLY_TOKENS = [
|
||||
"Re", // English, Italian, French (also generic ISO)
|
||||
"RE", // Outlook variant
|
||||
"AW", // German (Antwort)
|
||||
"Antw", // German verbose
|
||||
"Sv", // Danish / Swedish / Norwegian (Svar)
|
||||
"Yn", // Turkish (Yanit)
|
||||
"Yanit", // Turkish verbose
|
||||
"Odp", // Polish (Odpowiedz)
|
||||
"Ответ", // Russian
|
||||
"Resp", // Spanish/Portuguese variant
|
||||
"Vá", // Hungarian
|
||||
"回复", // Chinese
|
||||
"回覆", // Chinese traditional
|
||||
"답장", // Korean
|
||||
// NB: deliberately no bare "R" token — a single letter would strip the first
|
||||
// word of legitimate subjects like "R: budget 2024". The full "Re" covers
|
||||
// the common Italian/English case anyway.
|
||||
];
|
||||
|
||||
const FORWARD_TOKENS = [
|
||||
"Fwd", // English standard
|
||||
"Fw", // English short / Polish / German short
|
||||
"WG", // German (Weitergeleitet)
|
||||
"Tr", // French (Transfert)
|
||||
"Vs", // Danish (Videresend)
|
||||
"Enc", // Portuguese (Encaminhar)
|
||||
"ENC", // Portuguese caps
|
||||
"Rv", // Spanish (Reenviar)
|
||||
"RV", // Spanish caps
|
||||
"Rvf", // Spanish variant
|
||||
"Inol", // Italian (Inoltro)
|
||||
// NB: deliberately no bare "I" token — see the REPLY_TOKENS note above.
|
||||
"PD", // Polish (Przekazane Dalej)
|
||||
"PR", // Czech (Preposlat)
|
||||
"İlt", // Turkish (Ilet)
|
||||
"Ilt", // Turkish ASCII
|
||||
"Пересл", // Russian (Peresylka)
|
||||
"Пер", // Russian short
|
||||
"转发", // Chinese
|
||||
"轉寄", // Chinese traditional
|
||||
"전달", // Korean
|
||||
];
|
||||
|
||||
// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora)
|
||||
// + colon + whitespace. Case-insensitive. The non-capturing groups keep the
|
||||
// regex composable for stripping multiple prefixes in a row.
|
||||
function buildPrefixRegex(tokens: string[]): RegExp {
|
||||
// Escape regex specials in tokens (none currently, but be defensive)
|
||||
const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
// Sort by length DESC so longer tokens (e.g. "Пересл") win over their
|
||||
// shorter prefixes (e.g. "Пер") during alternation matching.
|
||||
escaped.sort((a, b) => b.length - a.length);
|
||||
return new RegExp(
|
||||
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`,
|
||||
"i",
|
||||
);
|
||||
}
|
||||
|
||||
const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]);
|
||||
|
||||
/**
|
||||
* Strip any leading sequence of reply/forward prefixes (across languages) from
|
||||
* a subject line. Idempotent and safe for empty input.
|
||||
*
|
||||
* Examples:
|
||||
* stripSubjectPrefixes("Re: AW: WG: foo") === "foo"
|
||||
* stripSubjectPrefixes("Re[2]: foo") === "foo"
|
||||
* stripSubjectPrefixes("RE: Re: foo") === "foo"
|
||||
* stripSubjectPrefixes("foo") === "foo"
|
||||
* stripSubjectPrefixes("") === ""
|
||||
*/
|
||||
export function stripSubjectPrefixes(subject: string | undefined | null): string {
|
||||
if (!subject) return "";
|
||||
let s = subject;
|
||||
// Bounded loop: in practice you never see more than ~10 prefixes; the bound
|
||||
// protects against pathological input. Each iteration must consume input.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const next = s.replace(ANY_PREFIX_RE, "");
|
||||
if (next === s) break;
|
||||
s = next;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a reply subject with the given locale-aware prefix. Strips any
|
||||
* pre-existing prefixes (in any language) first so chains don't accumulate.
|
||||
*
|
||||
* buildReplySubject("AW: WG: foo", "Re:") === "Re: foo"
|
||||
* buildReplySubject("foo", "AW:") === "AW: foo"
|
||||
* buildReplySubject("", "AW:") === "AW:"
|
||||
*/
|
||||
export function buildReplySubject(subject: string | undefined | null, prefix: string): string {
|
||||
const stripped = stripSubjectPrefixes(subject);
|
||||
return stripped ? `${prefix} ${stripped}` : prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a forward subject. Same logic as buildReplySubject but conceptually
|
||||
* separate for clarity at the call site.
|
||||
*/
|
||||
export function buildForwardSubject(subject: string | undefined | null, prefix: string): string {
|
||||
const stripped = stripSubjectPrefixes(subject);
|
||||
return stripped ? `${prefix} ${stripped}` : prefix;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user