From f5ed12f5cb61200fc98826d99c4126622c4cfa14 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:41:36 +0100 Subject: [PATCH 01/99] refactor: keep the density tables and legacy settings tabs in plain modules --- components/settings/settings-app.tsx | 25 ++-------- lib/density.ts | 68 ++++++++++++++++++++++++++++ lib/settings-tabs.ts | 35 ++++++++++++++ stores/settings-store.ts | 61 ++----------------------- 4 files changed, 111 insertions(+), 78 deletions(-) create mode 100644 lib/density.ts create mode 100644 lib/settings-tabs.ts diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 91fa6f634..dbe7ab821 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -1,6 +1,7 @@ "use client"; import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; +import { LEGACY_TAB_MAP } from "@/lib/settings-tabs"; import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations, useMessages } from 'next-intl'; @@ -154,27 +155,9 @@ const tabIcons: Record = { const tabGroupOrder: TabGroup[] = ['top', 'mail', 'apps', 'bottom']; -// Map legacy tab IDs to current ones; runs once on read of localStorage and -// for every deep link, so old bookmarks, connector links and sidebar gears -// land on the page that now holds the setting. -export const LEGACY_TAB_MAP: Record = { - email: 'reading', - advanced: 'backup', - layout: 'appearance', - themes: 'appearance', - content_senders: 'reading', - identities: 'account', - language: 'general', - protocol_handlers: 'general', - templates: 'composing', - folders: 'organize', - keywords: 'organize', - downloads: 'reading', - sidebar_apps: 'addons', - plugins: 'addons', - about_data: 'backup', - debug: 'backup', -}; +// Legacy tab ids and their current pages live in lib/settings-tabs.ts, which +// the pre-paint boot script reads too. +export { LEGACY_TAB_MAP }; // Where on the new page a legacy tab's settings now sit, so an old link // lands on that section rather than at the top of the page. diff --git a/lib/density.ts b/lib/density.ts new file mode 100644 index 000000000..4e1a4beb1 --- /dev/null +++ b/lib/density.ts @@ -0,0 +1,68 @@ +/** + * The CSS custom properties each list density writes on . + * + * Shared by the settings store, which applies them when JS runs, and by the + * boot script in app/(main)/layout.tsx, which applies the same values before + * the first paint so the skeleton and the loaded page share one row rhythm. + */ + +export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; +export type InterfaceLayout = 'default' | 'mountain-view'; + +export type DensityVars = Record; + +export const DEFAULT_DENSITY: Record = { + 'extra-compact': { + '--list-item-height': 'auto', + '--density-item-py': '2px', + '--density-item-gap': '6px', + '--density-header-py': '4px', + '--density-card-p': '8px', + '--density-sidebar-py': '0px', + }, + compact: { + '--list-item-height': 'auto', + '--density-item-py': '4px', + '--density-item-gap': '8px', + '--density-header-py': '6px', + '--density-card-p': '10px', + '--density-sidebar-py': '1px', + }, + regular: { + '--list-item-height': '48px', + '--density-item-py': '12px', + '--density-item-gap': '12px', + '--density-header-py': '12px', + '--density-card-p': '16px', + '--density-sidebar-py': '4px', + }, + comfortable: { + '--list-item-height': '64px', + '--density-item-py': '16px', + '--density-item-gap': '16px', + '--density-header-py': '16px', + '--density-card-p': '20px', + '--density-sidebar-py': '6px', + }, +}; + +/** + * Mountain View's rows come in three rhythms - 32, 40 and 48 pixels - and the + * app offers four densities. The four are laid over that scale: `compact`, + * `regular` and `comfortable` land on the three exactly, and `extra-compact` + * continues the series a step below, so every density still does something. + * + * Only the vertical rhythm is restated. Everything else a density touches - + * gaps, card padding, the sidebar's own row padding - keeps its default value; + * a navigation row is already pinned to 32px by the layout's own token. + */ +export const MOUNTAIN_VIEW_DENSITY: Record = { + 'extra-compact': { ...DEFAULT_DENSITY['extra-compact'], '--list-item-height': '28px', '--density-item-py': '4px' }, + compact: { ...DEFAULT_DENSITY.compact, '--list-item-height': '32px', '--density-item-py': '6px' }, + regular: { ...DEFAULT_DENSITY.regular, '--list-item-height': '40px', '--density-item-py': '10px' }, + comfortable: { ...DEFAULT_DENSITY.comfortable, '--list-item-height': '48px', '--density-item-py': '14px' }, +}; + +export function densityVarsFor(density: Density, layout: InterfaceLayout): DensityVars { + return (layout === 'mountain-view' ? MOUNTAIN_VIEW_DENSITY : DEFAULT_DENSITY)[density]; +} diff --git a/lib/settings-tabs.ts b/lib/settings-tabs.ts new file mode 100644 index 000000000..173203596 --- /dev/null +++ b/lib/settings-tabs.ts @@ -0,0 +1,35 @@ +import type { SettingsSearchTab } from '@/lib/settings-search'; + +/** + * Every settings page id, in no particular order. A `/settings/` link + * that names none of these (and no plugin page) falls back to the stored tab. + */ +export const SETTINGS_TAB_IDS: readonly SettingsSearchTab[] = [ + 'account', 'general', 'notifications', 'appearance', 'accessibility', + 'message_list', 'reading', 'composing', 'vacation', 'filters', 'organize', + 'security', 'sharing', 'recovery', 'calendar', 'contacts', 'files', + 'addons', 'backup', +]; + +// Map legacy tab IDs to current ones; runs once on read of localStorage and +// for every deep link, so old bookmarks, connector links and sidebar gears +// land on the page that now holds the setting. The pre-paint boot script +// uses it too, to pick the settings skeleton's active row. +export const LEGACY_TAB_MAP: Record = { + email: 'reading', + advanced: 'backup', + layout: 'appearance', + themes: 'appearance', + content_senders: 'reading', + identities: 'account', + language: 'general', + protocol_handlers: 'general', + templates: 'composing', + folders: 'organize', + keywords: 'organize', + downloads: 'reading', + sidebar_apps: 'addons', + plugins: 'addons', + about_data: 'backup', + debug: 'backup', +}; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2427a5965..6d79f88dd 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { densityVarsFor } from '@/lib/density'; import { persist } from 'zustand/middleware'; import { useThemeStore } from './theme-store'; import { useLocaleStore } from './locale-store'; @@ -2155,63 +2156,9 @@ function applyAccessibility(state: { fontSize: FontSize } & AccessibilitySetting applyAccessibilityTo(document.documentElement, state); } -type DensityVars = Record; - -const DEFAULT_DENSITY: Record = { - 'extra-compact': { - '--list-item-height': 'auto', - '--density-item-py': '2px', - '--density-item-gap': '6px', - '--density-header-py': '4px', - '--density-card-p': '8px', - '--density-sidebar-py': '0px', - }, - compact: { - '--list-item-height': 'auto', - '--density-item-py': '4px', - '--density-item-gap': '8px', - '--density-header-py': '6px', - '--density-card-p': '10px', - '--density-sidebar-py': '1px', - }, - regular: { - '--list-item-height': '48px', - '--density-item-py': '12px', - '--density-item-gap': '12px', - '--density-header-py': '12px', - '--density-card-p': '16px', - '--density-sidebar-py': '4px', - }, - comfortable: { - '--list-item-height': '64px', - '--density-item-py': '16px', - '--density-item-gap': '16px', - '--density-header-py': '16px', - '--density-card-p': '20px', - '--density-sidebar-py': '6px', - }, -}; - -/** - * Mountain View's rows come in three rhythms - 32, 40 and 48 pixels - and the - * app offers four densities. The four are laid over that scale: `compact`, - * `regular` and `comfortable` land on the three exactly, and `extra-compact` - * continues the series a step below, so every density still does something. - * - * Only the vertical rhythm is restated. Everything else a density touches - - * gaps, card padding, the sidebar's own row padding - keeps its default value; - * a navigation row is already pinned to 32px by the layout's own token. - */ -const MOUNTAIN_VIEW_DENSITY: Record = { - 'extra-compact': { ...DEFAULT_DENSITY['extra-compact'], '--list-item-height': '28px', '--density-item-py': '4px' }, - compact: { ...DEFAULT_DENSITY.compact, '--list-item-height': '32px', '--density-item-py': '6px' }, - regular: { ...DEFAULT_DENSITY.regular, '--list-item-height': '40px', '--density-item-py': '10px' }, - comfortable: { ...DEFAULT_DENSITY.comfortable, '--list-item-height': '48px', '--density-item-py': '14px' }, -}; - -export function densityVarsFor(density: Density, layout: InterfaceLayout): DensityVars { - return (layout === 'mountain-view' ? MOUNTAIN_VIEW_DENSITY : DEFAULT_DENSITY)[density]; -} +// The tables live in lib/density.ts so the pre-paint boot script can use +// the same values (app/(main)/layout.tsx). +export { densityVarsFor }; function applyDensity(density: Density, layout: InterfaceLayout) { if (typeof document === 'undefined') return; From 943276696bc1a9cc6f4fd0457a04ef4743a7d33a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:41:58 +0100 Subject: [PATCH 02/99] feat: paint the saved theme, density and layout before the app loads --- app/(main)/layout.tsx | 3 + .../__tests__/boot-script.test.ts | 464 ++++++++++++++++++ .../__tests__/theme-cache.test.ts | 75 +++ lib/boot-skeleton/boot-script.ts | 309 ++++++++++++ lib/boot-skeleton/keys.ts | 6 + lib/boot-skeleton/surfaces.ts | 79 +++ lib/theme-loader.ts | 55 +++ stores/theme-store.ts | 7 + 8 files changed, 998 insertions(+) create mode 100644 lib/boot-skeleton/__tests__/boot-script.test.ts create mode 100644 lib/boot-skeleton/__tests__/theme-cache.test.ts create mode 100644 lib/boot-skeleton/boot-script.ts create mode 100644 lib/boot-skeleton/keys.ts create mode 100644 lib/boot-skeleton/surfaces.ts diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 208656678..d186b72da 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -19,6 +19,7 @@ import { withBasePath } from "@/lib/browser-navigation"; import { locales, defaultLocale } from "@/i18n/routing"; import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite"; import { accessibilityBootScript } from "@/lib/accessibility-settings"; +import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script"; import "../globals.css"; import "../print.css"; @@ -314,10 +315,12 @@ export default async function RootLayout({ const resolved = theme === 'system' ? systemTheme : theme; document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.add(resolved); + document.documentElement.style.colorScheme = resolved; } catch (e) { document.documentElement.classList.add('light'); } ${accessibilityBootScript()} + ${bootSkeletonScript()} })(); `, }} diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts new file mode 100644 index 000000000..ee151e6bc --- /dev/null +++ b/lib/boot-skeleton/__tests__/boot-script.test.ts @@ -0,0 +1,464 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { bootConfig, bootSkeletonScript, type BootLayoutConfig } from '../boot-script'; +import { THEME_CACHE_KEY } from '../keys'; + +/** + * Runs the exact string the root layout inlines into , in a scope with + * none of this module's imports: a function that reached for one would throw + * here (and in the browser), and the test would see default attributes. + */ +function boot(path: string, config: Partial = {}) { + window.history.replaceState(null, '', path); + new Function(bootSkeletonScript({ ...bootConfig(), ...config }))(); +} + +const html = () => document.documentElement; +const attr = (name: string) => html().getAttribute(`data-boot-${name}`); +const cssVar = (name: string) => html().style.getPropertyValue(name); +const settings = (state: Record) => + localStorage.setItem('settings-storage', JSON.stringify({ state, version: 12 })); +const signedIn = () => + localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true }, version: 0 })); + +function desktop(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: matches && query.includes('64rem'), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + for (const a of [...html().attributes]) { + if (a.name.startsWith('data-boot-') || a.name === 'data-interface-layout' || a.name === 'data-theme-skin' || a.name === 'style') { + html().removeAttribute(a.name); + } + } + document.head.querySelectorAll('#active-theme, #active-theme-skin').forEach((el) => el.remove()); + desktop(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('boot script: defaults', () => { + it('draws the split inbox with the regular density and default widths when nothing is stored', () => { + boot('/en/mail/folder/inbox'); + expect(attr('surface')).toBe('mail'); + expect(attr('mail-layout')).toBe('split'); + expect(html().getAttribute('data-interface-layout')).toBe('default'); + expect(cssVar('--list-item-height')).toBe('48px'); + expect(cssVar('--density-item-py')).toBe('12px'); + expect(cssVar('--boot-mail-sidebar-w')).toBe('256px'); + expect(cssVar('--boot-mail-list-w')).toBe('384px'); + expect(cssVar('--boot-mail-list-h')).toBe('320px'); + expect(attr('pane')).toBeNull(); + expect(attr('pro')).toBeNull(); + }); + + it('treats the bare root as mail', () => { + boot('/'); + expect(attr('surface')).toBe('mail'); + }); + + it('survives corrupt JSON in every key it reads', () => { + for (const key of ['settings-storage', 'column-widths', 'calendar-storage', 'auth-storage', 'account-registry', 'bulwark-boot-hints', THEME_CACHE_KEY]) { + localStorage.setItem(key, '{not json'); + } + boot('/en/calendar'); + expect(attr('surface')).toBe('calendar'); + expect(attr('calendar-view')).toBe('month'); + expect(cssVar('--list-item-height')).toBe('48px'); + expect(cssVar('--boot-mail-sidebar-w')).toBe('256px'); + expect(attr('rail')).toBeNull(); + expect(document.getElementById('active-theme')).toBeNull(); + }); + + it('survives storage that throws on access', () => { + const throwing = { get: () => { throw new Error('SecurityError'); } }; + const local = Object.getOwnPropertyDescriptor(window, 'localStorage'); + const session = Object.getOwnPropertyDescriptor(window, 'sessionStorage'); + Object.defineProperty(window, 'localStorage', { configurable: true, ...throwing }); + Object.defineProperty(window, 'sessionStorage', { configurable: true, ...throwing }); + try { + boot('/en/settings/filters'); + expect(attr('surface')).toBe('settings'); + expect(attr('settings-tab')).toBe('filters'); + expect(cssVar('--boot-settings-nav-w')).toBe('256px'); + } finally { + Object.defineProperty(window, 'localStorage', local!); + Object.defineProperty(window, 'sessionStorage', session!); + } + }); + + it('ignores state that is not an object', () => { + localStorage.setItem('settings-storage', JSON.stringify({ state: 'oops' })); + localStorage.setItem('column-widths', JSON.stringify([1, 2])); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('split'); + expect(cssVar('--boot-mail-list-w')).toBe('384px'); + }); +}); + +describe('boot script: layout settings', () => { + it('applies Mountain View, its density rhythm and forces the focus layout', () => { + settings({ interfaceLayout: 'mountain-view', density: 'compact', mailLayout: 'split' }); + boot('/en/mail'); + expect(html().getAttribute('data-interface-layout')).toBe('mountain-view'); + expect(cssVar('--list-item-height')).toBe('32px'); + expect(cssVar('--density-item-py')).toBe('6px'); + expect(cssVar('--density-item-gap')).toBe('8px'); + expect(attr('mail-layout')).toBe('focus'); + }); + + it.each([ + ['extra-compact', 'auto', '2px', '0px'], + ['compact', 'auto', '4px', '1px'], + ['regular', '48px', '12px', '4px'], + ['comfortable', '64px', '16px', '6px'], + ])('writes the %s density variables', (density, height, py, sidebar) => { + settings({ density }); + boot('/en/mail'); + expect(cssVar('--list-item-height')).toBe(height); + expect(cssVar('--density-item-py')).toBe(py); + expect(cssVar('--density-sidebar-py')).toBe(sidebar); + }); + + it('falls back to regular for an unknown density', () => { + settings({ density: 'cosy' }); + boot('/en/mail'); + expect(cssVar('--list-item-height')).toBe('48px'); + }); + + it('keeps the horizontal and focus layouts, and simple mode forces focus', () => { + settings({ mailLayout: 'horizontal' }); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('horizontal'); + settings({ mailLayout: 'focus' }); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('focus'); + settings({ mailLayout: 'horizontal', simpleMode: true }); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('focus'); + }); + + it('clamps stored column widths the way the mail store does', () => { + localStorage.setItem('column-widths', JSON.stringify({ sidebarWidth: 1000, emailListWidth: 100, emailListHeight: 500 })); + boot('/en/mail'); + expect(cssVar('--boot-mail-sidebar-w')).toBe('400px'); + expect(cssVar('--boot-mail-list-w')).toBe('240px'); + expect(cssVar('--boot-mail-list-h')).toBe('500px'); + }); + + it('reads the other surfaces\' widths, defaulting bad values', () => { + localStorage.setItem('calendar-sidebar-width', '300'); + localStorage.setItem('contacts-sidebar-width', 'abc'); + localStorage.setItem('contacts-list-width', '450'); + localStorage.setItem('files-sidebar-width', '999'); + localStorage.setItem('settings-sidebar-width', '210'); + boot('/en/contacts'); + expect(cssVar('--boot-cal-sidebar-w')).toBe('300px'); + expect(cssVar('--boot-contacts-sidebar-w')).toBe('256px'); + expect(cssVar('--boot-contacts-list-w')).toBe('450px'); + expect(cssVar('--boot-files-sidebar-w')).toBe('400px'); + expect(cssVar('--boot-settings-nav-w')).toBe('210px'); + }); + + it('marks the files view and details panel', () => { + settings({ filesSettings: { defaultViewMode: 'grid' } }); + localStorage.setItem('files-show-details', '1'); + boot('/en/files/Documents'); + expect(attr('surface')).toBe('files'); + expect(attr('files-view')).toBe('grid'); + expect(attr('files-details')).toBe('on'); + }); +}); + +describe('boot script: mail and contacts panes', () => { + it.each([ + ['/en/mail/message/M123', 'message'], + ['/en/mail/thread/T9', 'thread'], + ['/en/mail?email=M1', 'message'], + ['/en/mail/folder/inbox', null], + ['/en/mail/message', null], + ])('%s opens pane %s', (path, pane) => { + boot(path); + expect(attr('pane')).toBe(pane); + }); + + it.each([ + ['/en/contacts', null], + ['/en/contacts/new', 'detail'], + ['/en/contacts/abc/edit', 'detail'], + ['/en/contacts?contactId=abc', 'detail'], + ])('%s opens pane %s', (path, pane) => { + boot(path); + expect(attr('surface')).toBe('contacts'); + expect(attr('pane')).toBe(pane); + }); +}); + +describe('boot script: calendar', () => { + it.each(['month', 'week', 'day', 'agenda', 'tasks'])('takes the %s view from the URL', (view) => { + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'agenda' } })); + boot(`/en/calendar/${view}/2026-11-04`); + expect(attr('calendar-view')).toBe(view); + }); + + it('opens a bare date in month view, like parseCalendarPath', () => { + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' } })); + boot('/en/calendar/2026-11-04'); + expect(attr('calendar-view')).toBe('month'); + }); + + it('uses the stored view for /calendar and event links, month without one', () => { + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'day' } })); + boot('/en/calendar'); + expect(attr('calendar-view')).toBe('day'); + boot('/en/calendar/event/E1'); + expect(attr('calendar-view')).toBe('day'); + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'year' } })); + boot('/en/calendar'); + expect(attr('calendar-view')).toBe('month'); + }); + + it('counts week columns and display hours from the settings', () => { + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-cols')).toBe('7'); + expect(cssVar('--boot-cal-hours')).toBe('12'); + settings({ calendarHideNonWorkingDays: true, calendarWorkingDays: [1, 2, 3, 4, 5], calendarDayStartHour: 7, calendarDayEndHour: 19 }); + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-cols')).toBe('5'); + expect(cssVar('--boot-cal-hours')).toBe('12'); + settings({ calendarLimitHours: false, calendarHideNonWorkingDays: true, calendarWorkingDays: [] }); + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-cols')).toBe('7'); + expect(cssVar('--boot-cal-hours')).toBe('24'); + settings({ calendarDayStartHour: 20, calendarDayEndHour: 8 }); + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-hours')).toBe('24'); + }); + + it('marks a collapsed calendar sidebar', () => { + localStorage.setItem('calendar-sidebar-collapsed', '1'); + boot('/en/calendar'); + expect(attr('collapsed')).toBe('calendar'); + }); +}); + +describe('boot script: settings', () => { + it('opens the page a URL names, migrating legacy ids', () => { + boot('/en/settings/themes'); + expect(attr('settings-tab')).toBe('appearance'); + expect(attr('pane')).toBe('detail'); + boot('/en/settings/filters/rules'); + expect(attr('settings-tab')).toBe('filters'); + }); + + it('falls back to the stored tab for an unknown or missing URL tab', () => { + localStorage.setItem('settings-active-tab', 'keywords'); + boot('/en/settings/no-such-tab'); + expect(attr('settings-tab')).toBe('organize'); + expect(attr('pane')).toBeNull(); + boot('/en/settings'); + expect(attr('settings-tab')).toBe('organize'); + }); + + it('defaults to appearance, and honours the one-shot sidebar gear link', () => { + boot('/en/settings'); + expect(attr('settings-tab')).toBe('appearance'); + sessionStorage.setItem('settings-deep-link-tab', 'folders'); + boot('/en/settings'); + expect(attr('settings-tab')).toBe('organize'); + expect(attr('pane')).toBe('detail'); + }); +}); + +describe('boot script: pages that lead elsewhere', () => { + it.each([ + ['/connector/app', 'mail', null], + ['/connector/mail_folder?ref=inbox', 'mail', null], + ['/connector/contacts', 'contacts', null], + ['/connector/files?path=/Docs', 'files', null], + ['/connector/setup', 'setup', null], + ['/connector/admin?tab=logs', 'admin', 'detail'], + ['/connector/admin', 'admin', null], + ['/connector/admin_plugin?id=x', 'admin', 'detail'], + ])('%s draws %s', (path, surface, pane) => { + boot(path); + expect(attr('surface')).toBe(surface); + expect(attr('via')).toBe('connector'); + expect(attr('pane')).toBe(pane); + }); + + it('draws a connector calendar link in its view and a settings link on its page', () => { + boot('/connector/calendar?view=agenda&date=2026-11-04'); + expect(attr('surface')).toBe('calendar'); + expect(attr('calendar-view')).toBe('agenda'); + boot('/connector/calendar'); + expect(attr('calendar-view')).toBe('month'); + boot('/connector/settings?tab=templates'); + expect(attr('surface')).toBe('settings'); + expect(attr('settings-tab')).toBe('composing'); + }); + + it('leaves an unknown connector target without a surface', () => { + boot('/connector/nope'); + expect(attr('surface')).toBeNull(); + }); + + it('draws mail behind mailto and the calendar behind webcal', () => { + boot('/protocol/mailto?url=mailto%3Aa%40b.c'); + expect(attr('surface')).toBe('mail'); + expect(attr('via')).toBe('mailto'); + boot('/protocol/webcal?url=webcal%3A%2F%2Fx'); + expect(attr('surface')).toBe('calendar'); + expect(attr('via')).toBe('webcal'); + }); + + it.each([ + ['/admin', 'admin', null], + ['/admin/branding', 'admin', 'detail'], + ['/admin/login', 'admin-login', null], + ['/setup', 'setup', null], + ['/en/login', 'login', null], + ['/en/pro', 'pro', null], + ['/en/apps/plugin-x/page-y', 'apps', null], + ])('%s is %s', (path, surface, pane) => { + boot(path); + expect(attr('surface')).toBe(surface); + expect(attr('pane')).toBe(pane); + }); +}); + +describe('boot script: Pro interface', () => { + it('predicts the hop to /pro on a desktop with the Pro interface on', () => { + settings({ proInterface: true }); + boot('/en/mail/folder/inbox'); + expect(attr('pro')).toBe('on'); + boot('/en/settings/appearance'); + expect(attr('pro')).toBe('on'); + boot('/en/pro'); + expect(attr('pro')).toBe('on'); + }); + + it('not on a narrow screen, not without the setting, not for other pages', () => { + settings({ proInterface: true }); + desktop(false); + boot('/en/mail'); + expect(attr('pro')).toBeNull(); + desktop(true); + boot('/admin'); + expect(attr('pro')).toBeNull(); + settings({ proInterface: false }); + boot('/en/mail'); + expect(attr('pro')).toBeNull(); + }); +}); + +describe('boot script: signed-out visitors', () => { + it('marks a visitor with nothing stored as signed out on app pages', () => { + boot('/en/mail'); + expect(attr('auth')).toBe('out'); + boot('/en/calendar/week'); + expect(attr('auth')).toBe('out'); + }); + + it('not on the login, admin or setup pages', () => { + for (const path of ['/en/login', '/admin', '/admin/login', '/setup']) { + boot(path); + expect(attr('auth')).toBeNull(); + } + }); + + it('not with a persisted session, a remembered account, a demo account or an impersonation hand-off', () => { + signedIn(); + boot('/en/mail'); + expect(attr('auth')).toBeNull(); + localStorage.clear(); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a', rememberMe: true }] } })); + boot('/en/mail'); + expect(attr('auth')).toBeNull(); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'd', rememberMe: false, serverUrl: 'https://demo.example.com' }] } })); + boot('/en/mail'); + expect(attr('auth')).toBeNull(); + localStorage.clear(); + boot('/en/mail?impersonated=1'); + expect(attr('auth')).toBeNull(); + }); + + it('counts an account that will be dropped (no remember me) as signed out', () => { + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a', rememberMe: false }] } })); + boot('/en/mail'); + expect(attr('auth')).toBe('out'); + boot('/en/mail', { lite: true }); + expect(attr('auth')).toBeNull(); + }); +}); + +describe('boot script: rail hints', () => { + it('passes the stored rail items on, dropping anything odd', () => { + localStorage.setItem('bulwark-boot-hints', JSON.stringify({ v: 1, rail: ['mail', 'calendar', 'quota', '', 3] })); + boot('/en/mail'); + expect(attr('rail')).toBe('mail calendar quota'); + }); +}); + +describe('boot script: Lite', () => { + it('reads a parked deep link instead of the surface root it was replayed on', () => { + sessionStorage.setItem('bulwark-lite:pending-path', '/de/calendar/day/2026-11-04'); + boot('/de/calendar/', { lite: true }); + expect(attr('surface')).toBe('calendar'); + expect(attr('calendar-view')).toBe('day'); + }); + + it('ignores the parked path outside Lite', () => { + sessionStorage.setItem('bulwark-lite:pending-path', '/de/calendar/day/2026-11-04'); + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' } })); + boot('/de/calendar/'); + expect(attr('calendar-view')).toBe('week'); + }); + + it('strips the base path and the Lite mount', () => { + boot('/webmail/en/files/Docs', { basePath: '/webmail' }); + expect(attr('surface')).toBe('files'); + (window as unknown as Record).__BULWARK_LITE_MOUNT__ = '/bulwark'; + try { + boot('/bulwark/en/settings/filters', { lite: true }); + expect(attr('surface')).toBe('settings'); + expect(attr('settings-tab')).toBe('filters'); + } finally { + delete (window as unknown as Record).__BULWARK_LITE_MOUNT__; + } + }); +}); + +describe('boot script: cached theme', () => { + it('puts the cached theme and skin back before the page renders', () => { + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, css: ':root{--color-primary:red}', skin: 'html[data-theme-skin="t1"] body{font-family:x}', skinId: 't1' })); + boot('/en/mail'); + expect(document.getElementById('active-theme')?.textContent).toBe(':root{--color-primary:red}'); + expect(document.getElementById('active-theme-skin')?.textContent).toContain('font-family:x'); + expect(html().getAttribute('data-theme-skin')).toBe('t1'); + }); + + it('reuses existing elements instead of adding a second copy', () => { + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, css: 'a{}' })); + boot('/en/mail'); + boot('/en/mail'); + expect(document.querySelectorAll('#active-theme')).toHaveLength(1); + }); + + it('ignores a cache from another format version, or a skin without its id', () => { + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 2, css: 'a{}' })); + boot('/en/mail'); + expect(document.getElementById('active-theme')).toBeNull(); + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, skin: 'b{}' })); + boot('/en/mail'); + expect(document.getElementById('active-theme-skin')).toBeNull(); + }); +}); diff --git a/lib/boot-skeleton/__tests__/theme-cache.test.ts b/lib/boot-skeleton/__tests__/theme-cache.test.ts new file mode 100644 index 000000000..66c414eed --- /dev/null +++ b/lib/boot-skeleton/__tests__/theme-cache.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + bootSkinCSS, + injectThemeCSS, + injectThemeSkinCSS, + removeThemeCSS, + removeThemeSkinCSS, +} from '@/lib/theme-loader'; +import { THEME_CACHE_KEY, THEME_CACHE_MAX } from '../keys'; + +const cache = () => JSON.parse(localStorage.getItem(THEME_CACHE_KEY) || 'null'); + +beforeEach(() => { + localStorage.clear(); + removeThemeCSS(); + removeThemeSkinCSS(); + document.documentElement.removeAttribute('data-theme-skin'); +}); + +describe('theme cache for the boot script', () => { + it('keeps a copy of the injected colour block and skin', () => { + injectThemeCSS(':root{--color-primary:#f00}'); + injectThemeSkinCSS('body[data-theme-skin="t1"] .toolbar{gap:2px}', 't1'); + expect(cache()).toEqual({ + v: 1, + css: ':root{--color-primary:#f00}', + skin: 'html[data-theme-skin="t1"] body .toolbar{gap:2px}', + skinId: 't1', + }); + }); + + it('drops the parts that are removed, and the whole entry when nothing is left', () => { + injectThemeCSS('a{}'); + injectThemeSkinCSS('b{}', 't1'); + removeThemeSkinCSS(); + expect(cache()).toEqual({ v: 1, css: 'a{}' }); + removeThemeCSS(); + expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull(); + }); + + it('does not cache a theme too large for localStorage', () => { + injectThemeCSS('a{}'); + injectThemeCSS(`:root{--x:"${'y'.repeat(THEME_CACHE_MAX)}"}`); + expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull(); + expect(document.getElementById('active-theme')?.textContent).toContain('--x'); + }); + + it('clears the boot copy\'s attribute once the real skin is on ', () => { + document.documentElement.setAttribute('data-theme-skin', 't1'); + injectThemeSkinCSS('b{}', 't1'); + expect(document.body.getAttribute('data-theme-skin')).toBe('t1'); + expect(document.documentElement.hasAttribute('data-theme-skin')).toBe(false); + }); + + it('reuses a

Hello Ana,

Tom & Jerry say &lt;hi&gt;

', + )); + + expect(view.text).toBe('Hello Ana,\n\nTom & Jerry say <hi>'); + }); + + it('keeps a text/plain body as it is', () => { + const view = emailToReadView({ + ...htmlOnly('

ignored

'), + textBody: [{ partId: '2', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { 1: { value: '

ignored

' }, 2: { value: 'a is literal\nline two' } }, + } as unknown as Email); + + expect(view.text).toBe('a is literal\nline two'); + }); +}); diff --git a/lib/html-to-text.ts b/lib/html-to-text.ts index bf04cee4d..cf12ae0bf 100644 --- a/lib/html-to-text.ts +++ b/lib/html-to-text.ts @@ -144,6 +144,9 @@ function nodesToPlainText(nodes: NodeListOf, options: HtmlToPlainText const element = node as HTMLElement; const tagName = element.tagName.toLowerCase(); + // A mail's own CSS and scripts are not part of what it says. + if (tagName === 'style' || tagName === 'script') return; + if (tagName === 'img') { const alt = element.getAttribute('alt')?.replace(/\s+/g, ' ').trim(); if (alt && options.imageAlt) appendText(options.imageAlt(alt)); diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index 7f2ec5aa9..40ec3d697 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -3,7 +3,7 @@ // hands plugins the same shape declared in plugin-types.ts. import type { - CalendarEvent, CalendarTask, ContactCard, Email, FileNode, Identity, VacationResponse, + CalendarEvent, CalendarTask, ContactCard, Email, EmailBodyPart, FileNode, Identity, VacationResponse, } from '@/lib/jmap/types'; import type { CalendarEventView, ContactView, EmailReadView, EmailSummary, FileResourceView, IdentityView, @@ -12,39 +12,32 @@ import type { import type { EmailTemplate } from '@/lib/template-types'; import { generateEmailSource } from '@/lib/email-source'; import { isServerSetKeyword } from '@/lib/jmap/server-keywords'; +import { htmlToPlainText } from '@/lib/html-to-text'; // 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. +// snippet. Per RFC 8621 textBody lists the text/html part when a message has +// no text/plain alternative, so each part is read by its own type: HTML is +// converted to text, never handed on as source. Falls back to preview. export function plainTextFromEmail(email: Email): string { const values = email.bodyValues || {}; - const collect = (parts?: { partId: string }[]) => + // A part without a type counts as the kind of list it is in. + const collect = (parts: EmailBodyPart[] | undefined, untyped: string) => (parts || []) - .map((p) => values[p.partId]?.value) - .filter((v): v is string => typeof v === 'string' && v.length > 0) + .map((p) => { + const value = values[p.partId]?.value; + if (typeof value !== 'string' || !value) return ''; + return (p.type || untyped).toLowerCase() === 'text/html' + ? htmlToPlainText(value, { paragraphSpacing: true, mailMarkup: true }) + : value; + }) + .filter((v) => v.trim().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(); + return collect(email.textBody, 'text/plain') + || collect(email.htmlBody, 'text/html') + || (email.preview || '').trim(); } /** From 732175772c8838b27cd9c2d6579a6e3d26eeb56e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:43:43 +0100 Subject: [PATCH 89/99] fix: carry a plugin's template or signature change into both rich and plain-text drafts --- hooks/use-plugin-signature.ts | 10 +++++++-- .../__tests__/identity-plugin-hooks.test.ts | 19 +++++++++++++++++ .../__tests__/template-plugin-hooks.test.ts | 21 +++++++++++++++++-- stores/template-store.ts | 18 +++++++++++++--- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/hooks/use-plugin-signature.ts b/hooks/use-plugin-signature.ts index cba4086a6..f64b376a5 100644 --- a/hooks/use-plugin-signature.ts +++ b/hooks/use-plugin-signature.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { Identity } from '@/lib/jmap/types'; import { identityHooks } from '@/lib/plugin-hooks'; +import { getPlainTextSignature, signatureAsHtml } from '@/lib/signature-utils'; interface RenderedSignature { identityId: string; @@ -43,9 +44,14 @@ export function usePluginSignature(identity: Identity | null | undefined, option void identityHooks.onSignatureRender.transform(initial, { identityId: id }).then((result: unknown) => { if (cancelled) return; const r = result as { html?: unknown; text?: unknown } | null; - const html = typeof r?.html === 'string' ? r.html : initial.html; - const text = typeof r?.text === 'string' ? r.text : initial.text; + let html = typeof r?.html === 'string' ? r.html : initial.html; + let text = typeof r?.text === 'string' ? r.text : initial.text; if (html === initial.html && text === initial.text) return; + // A plugin that changed one form only: the composer's other mode reads + // the other form, which would still be the old signature. An empty one + // needs nothing, as the composer falls back to the form that is set. + if (text === initial.text && initial.text) text = getPlainTextSignature({ htmlSignature: html }); + else if (html === initial.html && initial.html) html = signatureAsHtml({ textSignature: text }); setRendered({ identityId: id, source, html, text }); setRevision((n) => n + 1); }); diff --git a/stores/__tests__/identity-plugin-hooks.test.ts b/stores/__tests__/identity-plugin-hooks.test.ts index d0251f6f1..f9306cf81 100644 --- a/stores/__tests__/identity-plugin-hooks.test.ts +++ b/stores/__tests__/identity-plugin-hooks.test.ts @@ -143,6 +143,25 @@ describe('identity plugin hooks', () => { expect(result.current.identity?.id).toBe('id-1'); }); + // Plain-text mode reads textSignature, rich mode htmlSignature. + it('makes the text signature from the HTML when a plugin changed only the HTML', async () => { + identityHooks.onSignatureRender.register('test', (sig: { html: string; text: string }) => ({ ...sig, html: '

Ann & Bo

Sales

' })); + const ident = identity(); + const { result } = renderHook(() => usePluginSignature(ident)); + + await waitFor(() => expect(result.current.revision).toBe(1)); + expect(result.current.identity?.textSignature).toBe('Ann & Bo\nSales'); + }); + + it('makes the HTML signature from the text when a plugin changed only the text', async () => { + identityHooks.onSignatureRender.register('test', (sig: { html: string; text: string }) => ({ ...sig, text: 'Ann \n555' })); + const ident = identity(); + const { result } = renderHook(() => usePluginSignature(ident)); + + await waitFor(() => expect(result.current.revision).toBe(1)); + expect(result.current.identity?.htmlSignature).toBe('Ann <Sales>
555'); + }); + it('leaves the signature alone when no plugin listens', () => { const ident = identity(); const { result } = renderHook(() => usePluginSignature(ident)); diff --git a/stores/__tests__/template-plugin-hooks.test.ts b/stores/__tests__/template-plugin-hooks.test.ts index a412182ac..87834d887 100644 --- a/stores/__tests__/template-plugin-hooks.test.ts +++ b/stores/__tests__/template-plugin-hooks.test.ts @@ -50,7 +50,24 @@ describe('template plugin hooks', () => { const input = { subject: 'Hi', html: '

Hello

', text: 'Hello' }; expect(await renderTemplateWithPlugins('t1', input)).toBeNull(); - templateHooks.onTemplateRender.register('test', (value: typeof input) => ({ ...value, html: '

Changed

', text: 42 })); - expect(await renderTemplateWithPlugins('t1', input)).toEqual({ subject: 'Hi', html: '

Changed

', text: 'Hello' }); + templateHooks.onTemplateRender.register('test', (value: typeof input) => ({ ...value, subject: 42, html: '

Changed

', text: 'Changed too' })); + expect(await renderTemplateWithPlugins('t1', input)).toEqual({ subject: 'Hi', html: '

Changed

', text: 'Changed too' }); + }); + + // The composer takes the text in plain-text mode and the HTML in rich mode. + it('makes the text from the HTML when a plugin changed only the HTML', async () => { + const input = { subject: 'Hi', html: '

Hello

', text: 'Hello' }; + templateHooks.onTemplateRender.register('test', (value: typeof input) => ({ ...value, html: '

Hello & welcome

Ann

', text: 42 })); + expect(await renderTemplateWithPlugins('t1', input)).toEqual({ + subject: 'Hi', html: '

Hello & welcome

Ann

', text: 'Hello & welcome\n\nAnn', + }); + }); + + it('makes the HTML from the text when a plugin changed only the text', async () => { + const input = { subject: 'Hi', html: '

Hello

', text: 'Hello' }; + templateHooks.onTemplateRender.register('test', (value: typeof input) => ({ ...value, text: 'Hello \nAnn' })); + expect(await renderTemplateWithPlugins('t1', input)).toEqual({ + subject: 'Hi', html: '

Hello <team>
Ann

', text: 'Hello \nAnn', + }); }); }); diff --git a/stores/template-store.ts b/stores/template-store.ts index cfccd2728..6da0b30d6 100644 --- a/stores/template-store.ts +++ b/stores/template-store.ts @@ -15,6 +15,8 @@ import { notifyPersonalChange, useAccountTemplatesStore } from './account-templa import { withStorableId } from '@/lib/templates/metadata-templates'; import { templateHooks } from '@/lib/plugin-hooks'; import { templateToView } from '@/lib/plugin-projection'; +import { htmlToPlainText } from '@/lib/html-to-text'; +import { escapeHtml } from '@/lib/email-sanitization'; const MAX_RECENT = 5; @@ -191,7 +193,10 @@ export interface RenderedTemplate { * Hands a template about to be inserted into the composer to the * onTemplateRender plugins. Returns null when no plugin listens, so the * composer keeps its own rendering; otherwise the plugins' version, with any - * field a plugin returned as a non-string kept from the input. + * field a plugin returned as a non-string kept from the input. When a plugin + * changed only the HTML or only the text, the other is made from it: the + * composer uses one or the other depending on its mode, and the old one + * would leave the plugin's change out of a plain-text (or rich) draft. */ export async function renderTemplateWithPlugins( templateId: string, @@ -199,10 +204,17 @@ export async function renderTemplateWithPlugins( ): Promise { if (templateHooks.onTemplateRender.size === 0) return null; const result = await templateHooks.onTemplateRender.transform(rendered, { templateId }) as Partial | null; + let html = typeof result?.html === 'string' ? result.html : rendered.html; + let text = typeof result?.text === 'string' ? result.text : rendered.text; + if (html !== rendered.html && text === rendered.text) { + text = htmlToPlainText(html, { paragraphSpacing: true, mailMarkup: true }); + } else if (text !== rendered.text && html === rendered.html) { + html = `

${escapeHtml(text).replace(/\r?\n/g, '
')}

`; + } return { subject: typeof result?.subject === 'string' ? result.subject : rendered.subject, - html: typeof result?.html === 'string' ? result.html : rendered.html, - text: typeof result?.text === 'string' ? result.text : rendered.text, + html, + text, }; } From dfb946541ff6fdd1d02824c71d9d7eba607ca6af Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:32:33 +0100 Subject: [PATCH 90/99] fix: keep quote markers, list markers and image alt text when switching a reply to plain text --- .../email/__tests__/compose-format.test.ts | 18 +++++++++++++++++- components/email/compose-format.ts | 7 +++++-- components/email/email-composer.tsx | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/components/email/__tests__/compose-format.test.ts b/components/email/__tests__/compose-format.test.ts index 939c856c7..27123eef2 100644 --- a/components/email/__tests__/compose-format.test.ts +++ b/components/email/__tests__/compose-format.test.ts @@ -10,6 +10,8 @@ import { } from '../compose-format'; import { SIGNATURE_BLOCK_MARKER, SIGNATURE_RANGE_MARKER, containsEmbeddedSignature } from '../signature-block'; import { plainTextBodyHasSignature } from '@/lib/signature-utils'; +import { extractUserAuthoredText } from '@/lib/email-composer-utils'; +import { buildQuotedHtmlBlock } from '../quoted-html'; const identity = { htmlSignature: 'Alice
Acme', textSignature: 'Alice\nAcme' }; const count = (haystack: string, needle: string) => haystack.split(needle).length - 1; @@ -46,10 +48,24 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => { it('does not invent a signature for a body that had none (below-quote reply)', () => { const text = htmlComposeBodyToPlainText('

Thanks

original
', identity, { separator: true }); - expect(text).toBe('Thanks\n\noriginal'); + expect(text).toBe('Thanks\n\n> original'); expect(plainTextBodyHasSignature(text, identity)).toBe(false); }); + it('keeps the quoted original of a rich reply marked with "> "', () => { + const html = `

Thanks

On Monday Bob wrote:

${buildQuotedHtmlBlock('

Line one
Line two

Para two

')}`; + const text = htmlComposeBodyToPlainText(html, identity, { separator: true }); + expect(text).toBe('Thanks\n\nOn Monday Bob wrote:\n\n> Line one\n> Line two\n>\n> Para two'); + // The attachment reminder and plugin edits only see the user's text. + expect(extractUserAuthoredText(text, { plainTextMode: true })).not.toContain('Line one'); + }); + + it('keeps list markers and image alt text', () => { + const html = '
  • apples
  • pears
  1. first
  2. second

a bike

'; + const text = htmlComposeBodyToPlainText(html, identity, { separator: true, imageAlt: (alt) => `[Image: ${alt}]` }); + expect(text).toBe('- apples\n- pears\n\n1. first\n2. second\n\n[Image: a bike]'); + }); + it('honours the separator setting', () => { const html = `

Hi

${buildEmbeddedSignatureHtml(identity, { embed: true, separator: false })}`; expect(htmlComposeBodyToPlainText(html, identity, { separator: false })).toBe('Hi\n\nAlice\nAcme'); diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts index 2d3b83cf7..b8f262804 100644 --- a/components/email/compose-format.ts +++ b/components/email/compose-format.ts @@ -202,14 +202,17 @@ export function replacePlainTextSignatureAboveQuote( * Convert the rich-text editor's body to a plain-text body. Formatting is * dropped (that is the point of plain text); an embedded signature comes back * as the identity's plain-text signature so the body still "carries" it. + * The quote keeps its "> " lines and lists their markers, as in the text part + * the send path builds: the attachment reminder and plugin edits find the + * quote by them. */ export function htmlComposeBodyToPlainText( html: string, identity: SignatureIdentityLike, - options: { separator: boolean }, + options: { separator: boolean; imageAlt?: (alt: string) => string }, ): string { const stripped = stripEmbeddedSignature(html); - const text = htmlToPlainText(stripped.html, { paragraphSpacing: true }); + const text = htmlToPlainText(stripped.html, { paragraphSpacing: true, mailMarkup: true, imageAlt: options.imageAlt }); if (!stripped.hadSignature) return text; return appendPlainTextSignature(text, identity, { separator: options.separator }); } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index e2aaabb1b..9c1bf9184 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1951,12 +1951,12 @@ export function EmailComposer({ const editor = editorRef.current; const html = editor && !editor.isDestroyed ? serializeEditorContent(editor) : body; editorRef.current = null; - setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator })); + setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator, imageAlt: imageAltText })); setPlainTextMode(true); // Keyed on the signature fields, not the identity object (see the // signature effects above for the same rationale). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled]); + }, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, imageAltText]); // Move a chip from one recipient field to another. `toIndex`, when given, // inserts at that position in the destination (drag-and-drop reordering, From 2ae9e9c2f96629236e3ddaaca7d6308368365447 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:34:52 +0100 Subject: [PATCH 91/99] fix: turn quoted lines back into a quote when switching a reply to rich text --- .../email/__tests__/compose-format.test.ts | 19 +++++++++++- components/email/compose-format.ts | 31 ++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/components/email/__tests__/compose-format.test.ts b/components/email/__tests__/compose-format.test.ts index 27123eef2..262c13019 100644 --- a/components/email/__tests__/compose-format.test.ts +++ b/components/email/__tests__/compose-format.test.ts @@ -85,10 +85,27 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => { it('keeps a body without signature signature-free', () => { const html = plainComposeBodyToHtml('Thanks\n\n> original', identity, { separator: true }); - expect(html).toBe('

Thanks

> original

'); + expect(html).toBe('

Thanks

original

'); expect(containsEmbeddedSignature(html)).toBe(false); }); + it('turns "> " lines back into a quote, one level per marker', () => { + const text = 'Thanks\n\nOn Monday Bob wrote:\n> Line one\n> Line two\n>\n> > older\nnot quoted'; + expect(plainComposeBodyToHtml(text, identity, { separator: true })).toBe( + '

Thanks

On Monday Bob wrote:

' + + '

Line one
Line two

older

' + + '

not quoted

', + ); + }); + + it('round-trips a quoted reply through plain text and back', () => { + const start = `

Thanks

On Monday Bob wrote:

${buildQuotedHtmlBlock('

Line one

older

')}`; + const text = htmlComposeBodyToPlainText(start, identity, { separator: true }); + const back = plainComposeBodyToHtml(text, identity, { separator: true }); + expect(back).toBe('

Thanks

On Monday Bob wrote:

Line one

older

'); + expect(htmlComposeBodyToPlainText(back, identity, { separator: true })).toBe(text); + }); + it('gives an untouched signature-only body an empty paragraph to type into', () => { const html = plainComposeBodyToHtml('\n\n-- \nAlice\nAcme', identity, { separator: true }); expect(html.startsWith('

')).toBe(true); diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts index b8f262804..08729dd87 100644 --- a/components/email/compose-format.ts +++ b/components/email/compose-format.ts @@ -217,11 +217,34 @@ export function htmlComposeBodyToPlainText( return appendPlainTextSignature(text, identity, { separator: options.separator }); } +const QUOTE_LINE = /^>/; + +// Plain text to editor HTML, with runs of "> " lines as a
(one +// level per ">"), so a quote stays a quote in the rich editor. +function plainTextWithQuotesToHtml(text: string): string { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + if (!lines.some((line) => QUOTE_LINE.test(line))) return plainTextToComposerBody(text); + const parts: string[] = []; + let i = 0; + while (i < lines.length) { + const quoted = QUOTE_LINE.test(lines[i]); + const run: string[] = []; + while (i < lines.length && QUOTE_LINE.test(lines[i]) === quoted) run.push(lines[i++]); + if (quoted) { + parts.push(`
${plainTextWithQuotesToHtml(run.map((line) => line.replace(/^> ?/, '')).join('\n')) || '

'}
`); + } else { + parts.push(plainTextToComposerBody(run.join('\n').replace(/^\n+|\n+$/g, ''))); + } + } + return parts.join(''); +} + /** * Convert a plain-text body to editor HTML: paragraphs per blank line,
- * per newline. A trailing plain-text signature is swapped for the embedded - * (marker-bracketed) HTML signature so identity switches and the send path - * keep working as in a body that started out as HTML. + * per newline, "> " lines as a quote. A trailing plain-text signature is + * swapped for the embedded (marker-bracketed) HTML signature so identity + * switches and the send path keep working as in a body that started out as + * HTML. */ export function plainComposeBodyToHtml( text: string, @@ -230,7 +253,7 @@ export function plainComposeBodyToHtml( ): string { const hadSignature = plainTextBodyHasSignature(text, identity); const userText = hadSignature ? plainTextBodyWithoutSignature(text, identity) : text; - const html = plainTextToComposerBody(userText); + const html = plainTextWithQuotesToHtml(userText); if (!hadSignature) return html; const embedded = buildEmbeddedSignatureHtml(identity, { embed: true, separator: options.separator }); return `${html || '

'}${embedded}`; From c29a01a1331169c2940e1974fcc2475d4fceac28 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:38:40 +0100 Subject: [PATCH 92/99] fix: add a cloud attachment link in the format the message has when the upload finishes --- ...mposer-external-attachment-format.test.tsx | 293 ++++++++++++++++++ components/email/email-composer.tsx | 12 +- 2 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 components/email/__tests__/composer-external-attachment-format.test.tsx diff --git a/components/email/__tests__/composer-external-attachment-format.test.tsx b/components/email/__tests__/composer-external-attachment-format.test.tsx new file mode 100644 index 000000000..3af09d8c9 --- /dev/null +++ b/components/email/__tests__/composer-external-attachment-format.test.tsx @@ -0,0 +1,293 @@ +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +// A plugin can offload a large attachment (onBeforeBlobUpload) and hand back +// a link for the body. While it works, the user can switch the message +// between rich text and plain text. The link must go in in the format the +// body has when it arrives, not the one it had when the file was added. + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: false, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +// The offloading plugin: resolves when the test says so. +const { offload } = vi.hoisted(() => { + const offload: { finish: (value: unknown) => void } = { finish: () => {} }; + return { offload }; +}); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + onBeforeAttachmentUpload: { size: 0, intercept: async () => true }, + onBeforeBlobUpload: { + transform: () => new Promise((resolve) => { offload.finish = resolve; }), + }, + onAfterAttachmentUpload: { emit: () => {} }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, + isExternalAttachmentResult: (value: unknown) => + typeof value === 'object' && value !== null && (value as { externalAttachment?: boolean }).externalAttachment === true, +})); + +vi.mock('@/lib/plugin-storage', () => ({ + fileStorage: { + saveFile: async () => {}, + getFile: async () => null, + deleteFile: async () => {}, + }, +})); + +vi.mock('@/lib/upload-progress', () => ({ + onUploadProgress: () => () => {}, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +const LINK = { + externalAttachment: true, + html: '

report.pdf

', + text: 'report.pdf: https://cloud.example/s/abc', +}; + +const DRAFT = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Report', + body: '

See the file

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const toggle = () => { + if (!screen.queryByTestId('composer-format-toggle')) fireEvent.click(screen.getByTestId('composer-options-menu')); + return screen.getByTestId('composer-format-toggle') as HTMLButtonElement; +}; +const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement | null; + +async function attachAndWaitForPlugin() { + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function finishOffload() { + await act(async () => { + offload.finish(LINK); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +describe('composer: cloud attachment link after a format switch', () => { + afterEach(() => { + useAuthStore.setState({ client: null }); + useSettingsStore.setState({ plainTextMode: false }); + vi.clearAllMocks(); + }); + + function mockClient() { + useAuthStore.setState({ + client: { + uploadBlob: vi.fn().mockResolvedValue({ blobId: 'blob-1' }), + hasDelayedSend: () => false, + getMaxDelayedSend: () => 0, + } as never, + }); + } + + it('puts the plain-text link in when switched to plain text meanwhile', async () => { + mockClient(); + render(); + await attachAndWaitForPlugin(); + + fireEvent.click(toggle()); + expect(textarea()).not.toBeNull(); + await finishOffload(); + + expect(textarea()!.value).toBe(`See the file\n${LINK.text}`); + }); + + it('puts the HTML link in when switched to rich text meanwhile', async () => { + mockClient(); + useSettingsStore.setState({ plainTextMode: true }); + const onSend = vi.fn(); + render(); + await attachAndWaitForPlugin(); + + fireEvent.click(toggle()); + expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument(); + await finishOffload(); + + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + const sent = onSend.mock.calls[0][0] as { htmlBody?: string }; + expect(sent.htmlBody).toContain('report.pdf'); + expect(sent.htmlBody).not.toContain(LINK.text); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9c1bf9184..6bd117edf 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -515,6 +515,10 @@ export function EmailComposer({ const [plainTextMode, setPlainTextMode] = useState( () => initialDataProp?.plainTextMode ?? plainTextModeDefault ); + // The format right now, for async work that writes into the body after an + // await: the user may have switched it meanwhile. + const plainTextModeRef = useRef(plainTextMode); + plainTextModeRef.current = plainTextMode; // A plugin's onBeforeCompose prefill (plain text body) folded into what the // composer starts from: over `initialData` when the host hands one in (a // mailto:, a re-opened draft), otherwise over the fields built below. @@ -2382,7 +2386,9 @@ export function EmailComposer({ // offloading it - don't drop a link for a file they cancelled. if (controller?.signal.aborted) continue; setAttachments(prev => prev.filter(att => att.file !== file)); - if (plainTextMode) { + // The ref, not the closure: the user may have switched format + // while the handler was offloading the file. + if (plainTextModeRef.current) { setBody(previous => previous && !previous.endsWith('\n') ? `${previous}\n${transformed.text}` : previous + transformed.text ); @@ -2453,7 +2459,7 @@ export function EmailComposer({ ); } } - }, [client, t, plainTextMode]); + }, [client, t]); const handleFileSelect = async (event: React.ChangeEvent) => { if (!event.target.files) return; @@ -2957,8 +2963,6 @@ export function EmailComposer({ // catalogue): plain text at the caret, replacing the selection, so a // rewrite of a selected paragraph is one call and Ctrl+Z takes it back in // the rich editor. Stable, so the slot iframes don't get new props. - const plainTextModeRef = useRef(plainTextMode); - plainTextModeRef.current = plainTextMode; const pluginInsertText = useCallback((value: unknown) => { if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PLUGIN_INSERT_LENGTH) return false; if (plainTextModeRef.current) { From 8c1556117dae7eb985b7ae8ba0cb2277aea33276 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:03 +0100 Subject: [PATCH 93/99] fix: quote the text part in a rich reply when the html part is a bare wrapper without line breaks --- lib/__tests__/email-composer-utils.test.ts | 18 ++++++++++++++++-- lib/email-composer-utils.ts | 8 +++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index c835f0963..aef605701 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -649,11 +649,25 @@ describe("getQuoteBodies", () => { htmlBody: [part("h", "text/html")], bodyValues: { t: { value: "plain version" }, - h: { value: "

html version

" }, + h: { value: "

html version

" }, }, }); expect(body).toBe("plain version"); - expect(htmlBody).toBe("

html version

"); + expect(htmlBody).toBe("

html version

"); + }); + + it("quotes the text part when the HTML part is a bare wrapper without line breaks", () => { + const text = "Line one\nLine two\nLine three"; + const { body, htmlBody } = getQuoteBodies({ + textBody: [part("t", "text/plain")], + htmlBody: [part("h", "text/html")], + bodyValues: { + t: { value: text }, + h: { value: "Line one\nLine two\nLine three" }, + }, + }); + expect(body).toBe(text); + expect(htmlBody).toBeUndefined(); }); it("falls back to the preview when body values are missing", () => { diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index 0ac4babef..cc820fc70 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -1,6 +1,7 @@ import { isValidEmail } from "@/lib/validation"; import { splitMailbox } from "@/lib/rfc5322-mailbox"; import { htmlToPlainText } from "@/lib/html-to-text"; +import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { Ellipsis, Lock, TriangleAlert } from "@/components/icons"; import type { Email } from "@/lib/jmap/types"; @@ -48,9 +49,14 @@ export function getQuoteBodies( const body = textValue ? (textPartIsHtml ? htmlToPlainText(textValue, { paragraphSpacing: true }) : textValue) : (email.preview || ""); + // Like the viewer (getRenderableHtmlBody): an HTML part that is only a bare + // wrapper around the text, next to a real text part, often has no
, so + // quoting it would run every line together. Quote the text part instead. + const bareHtmlBesideText = !!htmlValue && !!textValue && !textPartIsHtml + && textPart?.partId !== htmlPart?.partId && !hasMeaningfulHtmlBody(htmlValue); return { body, - htmlBody: htmlPartIsHtml ? htmlValue || undefined : undefined, + htmlBody: htmlPartIsHtml && !bareHtmlBesideText ? htmlValue || undefined : undefined, }; } From 798f0ca0c8e354f88826125c78e56a2654493d71 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:36:56 +0100 Subject: [PATCH 94/99] fix: show an HTML event description as text instead of its markup --- .../__tests__/event-description-html.test.tsx | 70 +++++++++++++++++++ components/calendar/calendar-print.tsx | 6 +- components/calendar/event-detail-popover.tsx | 6 +- components/calendar/event-modal.tsx | 9 ++- .../email/calendar-invitation-banner.tsx | 9 ++- components/global-search/search-preview.tsx | 4 +- lib/__tests__/calendar-ics-export.test.ts | 16 +++++ lib/calendar-description.ts | 22 ++++++ lib/calendar-ics-export.ts | 5 +- 9 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 components/calendar/__tests__/event-description-html.test.tsx create mode 100644 lib/calendar-description.ts diff --git a/components/calendar/__tests__/event-description-html.test.tsx b/components/calendar/__tests__/event-description-html.test.tsx new file mode 100644 index 000000000..064030ee5 --- /dev/null +++ b/components/calendar/__tests__/event-description-html.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import { EventModal } from '../event-modal'; +import { EventPrintSheet } from '../calendar-print'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Other JMAP clients, and ICS files with STYLED-DESCRIPTION, store an HTML +// description with descriptionContentType text/html. The calendar showed its +// markup as text; it now shows the text. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +const calendar = { + id: 'cal-1', name: 'Work', isDefault: true, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayDelete: true }, +} as unknown as Calendar; + +const HTML = '

Agenda:
Budget & plans

Bring notes

'; + +function htmlEvent(): CalendarEvent { + return { + id: 'evt-1', uid: 'u1', '@type': 'Event', calendarIds: { 'cal-1': true }, title: 'Review', + description: HTML, descriptionContentType: 'text/html', + start: '2026-11-20T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, + participants: null, alerts: null, useDefaultAlerts: false, recurrenceRules: null, + } as unknown as CalendarEvent; +} + +const TEXT = 'Agenda:\nBudget & plans\n\nBring notes'; + +describe('HTML event descriptions', () => { + it('shows the text in the detail popover', () => { + render( + , + ); + // The popover renders in a portal. + const text = document.body.textContent; + expect(text).not.toContain('

'); + expect(text).not.toContain('&'); + expect(text).toContain('Budget & plans'); + }); + + it('shows the text in the editor, read and edit', () => { + const { container } = render( + , + ); + expect(container.textContent).not.toContain('

'); + expect(container.textContent).toContain('Budget & plans'); + fireEvent.click(screen.getByText('events.edit')); + expect((screen.getByPlaceholderText('form.description') as HTMLTextAreaElement).value).toBe(TEXT); + }); + + it('prints the text', () => { + const { container } = render( + , + ); + const text = container.querySelector('.pr-text')?.textContent; + expect(text).toBe(TEXT); + }); + + it('leaves a plain-text description as it is', () => { + const event = { ...htmlEvent(), description: 'a c', descriptionContentType: 'text/plain' }; + const { container } = render( + , + ); + expect(container.querySelector('.pr-text')?.textContent).toBe('a c'); + }); +}); diff --git a/components/calendar/calendar-print.tsx b/components/calendar/calendar-print.tsx index 353c45c88..e47299a47 100644 --- a/components/calendar/calendar-print.tsx +++ b/components/calendar/calendar-print.tsx @@ -33,6 +33,7 @@ import { formatDisplayHour, remapSegmentsToShownDays, resolveDisplayHours, resol import { groupTasksByDueDay } from "@/lib/calendar-tasks"; import { eventOrganizer, getParticipantList, isDeclinedByUser } from "@/lib/calendar-participants"; import { findMeetingLink, primaryLocationName } from "@/lib/event-links"; +import { descriptionAsText } from "@/lib/calendar-description"; import { PRINT_LANE_MM, PRINT_PAGE_HEIGHT_MM, @@ -699,6 +700,7 @@ export function EventPrintSheet({ const location = primaryLocationName(event); const meeting = findMeetingLink(event); + const description = descriptionAsText(event); const participants = getParticipantList(event, { resolveName: resolveContactName }); const organizer = eventOrganizer(event); const organizerName = organizer ? (organizer.name || resolveContactName(organizer.email) || "") : ""; @@ -754,10 +756,10 @@ export function EventPrintSheet({ )} - {event.description && ( + {description && (

{t("form.description")}

-

{event.description}

+

{description}

)} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 2d3def25a..57c4ea3e2 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -25,6 +25,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { descriptionAsText } from "@/lib/calendar-description"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; @@ -174,6 +175,7 @@ export function EventDetailPopover({ // join URL in the description; surface it as the meeting link anyway. const meeting = useMemo(() => findMeetingLink(event), [event]); const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]); + const description = useMemo(() => descriptionAsText(event), [event]); const virtualLocation = meeting?.uri ?? null; // A URL opens as a link, a location that only names the service joins the @@ -630,11 +632,11 @@ export function EventDetailPopover({ )} {/* Description */} - {event.description && ( + {description && (

- +

)} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 08a70b9ef..7e903ab0d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -9,6 +9,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { descriptionAsText } from "@/lib/calendar-description"; import { ParticipantStatusBadge } from "./rsvp-buttons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; @@ -371,7 +372,9 @@ export function EventModal({ }; const [title, setTitle] = useState(event?.title || defaultTitle || ""); - const [description, setDescription] = useState(event?.description || ""); + // An HTML description is shown and edited as its text. + const eventDescription = useMemo(() => (event ? descriptionAsText(event) : ""), [event]); + const [description, setDescription] = useState(eventDescription); const [location, setLocation] = useState( event?.locations ? Object.values(event.locations)[0]?.name || "" : "" ); @@ -1189,11 +1192,11 @@ export function EventModal({ )} {/* Description */} - {event.description && ( + {eventDescription && (

- +

)} diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 007880c61..03d7ed0fd 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -44,6 +44,7 @@ import { participantsAskedAgain } from '@/lib/calendar-participants'; import { planCancellationRemoval, sameRecurrenceId, type CancellationRemoval } from '@/lib/calendar-cancellation'; import { cn } from '@/lib/utils'; import { getEventStartDate } from '@/lib/calendar-utils'; +import { descriptionAsText } from '@/lib/calendar-description'; import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; @@ -331,11 +332,13 @@ function buildInvitationChangeItems( }); } - if ((currentEvent.description ?? '') !== (proposedEvent.description ?? '') && proposedEvent.description) { + const currentDescription = descriptionAsText(currentEvent); + const proposedDescription = descriptionAsText(proposedEvent); + if (currentDescription !== proposedDescription && proposedDescription) { changes.push({ label: t('change_description'), - before: currentEvent.description || t('change_empty'), - after: proposedEvent.description, + before: currentDescription || t('change_empty'), + after: proposedDescription, }); } diff --git a/components/global-search/search-preview.tsx b/components/global-search/search-preview.tsx index 2b519e1ae..44d010e1c 100644 --- a/components/global-search/search-preview.tsx +++ b/components/global-search/search-preview.tsx @@ -8,6 +8,7 @@ import type { CalendarHit, ContactHit, FileHit, GlobalSearchHit } from "@/lib/gl import { getContactDisplayName, getContactPhotoRef, getContactPrimaryEmail } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { cn, formatFileSize } from "@/lib/utils"; +import { descriptionAsText } from "@/lib/calendar-description"; function formatDateTime(iso: string | null): string { if (!iso) return ''; @@ -98,6 +99,7 @@ function ContactPreview({ hit, onOpen, openLabel }: { hit: ContactHit; onOpen: ( function CalendarPreview({ hit, onOpen, openLabel, recurringLabel }: { hit: CalendarHit; onOpen: (hit: GlobalSearchHit) => void; openLabel: string; recurringLabel: string }) { const event = hit.event; + const description = descriptionAsText(event); const locations = Object.values(event.locations ?? {}).map((l) => l?.name).filter(Boolean); const participants = Object.values(event.participants ?? {}) .map((p) => (p as { name?: string | null; email?: string | null })) @@ -121,7 +123,7 @@ function CalendarPreview({ hit, onOpen, openLabel, recurringLabel }: { hit: Cale
{formatDateTime(event.start)} {locations.map((location) => {location})} - {event.description && {event.description}} + {description && {description}} {participants.length > 0 && {participants.join(', ')}}
diff --git a/lib/__tests__/calendar-ics-export.test.ts b/lib/__tests__/calendar-ics-export.test.ts index d684938c6..fb98028d2 100644 --- a/lib/__tests__/calendar-ics-export.test.ts +++ b/lib/__tests__/calendar-ics-export.test.ts @@ -52,4 +52,20 @@ describe('eventToICS', () => { expect(line(ics, 'ORGANIZER')).toBe('ORGANIZER;CN=Alice:mailto:alice@example.com'); }); + + // DESCRIPTION is plain text: an HTML description goes out as its text. + it('writes an HTML description as text', () => { + const ics = eventToICS(event({ + description: '

Budget & plans
Room 2

', + descriptionContentType: 'text/html', + })); + + expect(line(ics, 'DESCRIPTION:')).toBe('DESCRIPTION:Budget & plans\\nRoom 2'); + }); + + it('writes a plain-text description as it is', () => { + const ics = eventToICS(event({ description: 'a c', descriptionContentType: 'text/plain' })); + + expect(line(ics, 'DESCRIPTION:')).toBe('DESCRIPTION:a c'); + }); }); diff --git a/lib/calendar-description.ts b/lib/calendar-description.ts new file mode 100644 index 000000000..5df68544f --- /dev/null +++ b/lib/calendar-description.ts @@ -0,0 +1,22 @@ +import { htmlToPlainText } from '@/lib/html-to-text'; + +interface DescribedEvent { + description?: string | null; + descriptionContentType?: string | null; +} + +/** Whether the description is HTML: other JMAP clients, and ICS files with STYLED-DESCRIPTION, can store it that way. */ +export function isHtmlDescription(event: DescribedEvent): boolean { + return /^\s*text\/html\b/i.test(event.descriptionContentType ?? ''); +} + +/** + * The event's description as plain text, for showing, editing and exporting. + * An HTML description is turned into readable text rather than rendered, so + * its markup never shows up as source and never reaches the page as HTML. + */ +export function descriptionAsText(event: DescribedEvent): string { + const description = event.description ?? ''; + if (!description || !isHtmlDescription(event)) return description; + return htmlToPlainText(description, { paragraphSpacing: true }); +} diff --git a/lib/calendar-ics-export.ts b/lib/calendar-ics-export.ts index 795147bae..a8e155016 100644 --- a/lib/calendar-ics-export.ts +++ b/lib/calendar-ics-export.ts @@ -1,4 +1,5 @@ import type { CalendarEvent } from "@/lib/jmap/types"; +import { descriptionAsText } from "@/lib/calendar-description"; const MAX_LINE_OCTETS = 75; const utf8 = new TextEncoder(); @@ -139,7 +140,9 @@ export function eventToICS(event: CalendarEvent): string { } if (event.title) lines.push(`SUMMARY:${escapeText(event.title)}`); - if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`); + // DESCRIPTION is plain text; an HTML description goes out as its text. + const description = descriptionAsText(event); + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`); if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`); if (event.privacy) lines.push(`CLASS:${event.privacy.toUpperCase()}`); if (event.freeBusyStatus) { From 7ab900a0f7918d771c32197f60ff84fa707d3e9d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:44:40 +0100 Subject: [PATCH 95/99] fix: save an edited HTML event description as plain text and keep notes in its format --- .../__tests__/event-description-html.test.tsx | 37 ++++++++++++++++++- components/calendar/calendar-app.tsx | 10 ++--- components/calendar/event-modal.tsx | 11 ++++-- lib/__tests__/calendar-description.test.ts | 24 ++++++++++++ .../plugin-host-calendar-tasks.test.ts | 10 +++++ lib/calendar-description.ts | 36 ++++++++++++++++++ lib/plugin-sandbox/host-api-calendar.ts | 3 ++ 7 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 lib/__tests__/calendar-description.test.ts diff --git a/components/calendar/__tests__/event-description-html.test.tsx b/components/calendar/__tests__/event-description-html.test.tsx index 064030ee5..8c3becab0 100644 --- a/components/calendar/__tests__/event-description-html.test.tsx +++ b/components/calendar/__tests__/event-description-html.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { EventDetailPopover } from '../event-detail-popover'; import { EventModal } from '../event-modal'; @@ -68,3 +68,38 @@ describe('HTML event descriptions', () => { expect(container.querySelector('.pr-text')?.textContent).toBe('a c'); }); }); + +describe('saving an HTML event description from the editor', () => { + async function save(event: CalendarEvent, newDescription?: string): Promise> { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByText('events.edit')); + if (newDescription !== undefined) { + fireEvent.change(screen.getByPlaceholderText('form.description'), { target: { value: newDescription } }); + } + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0][0] as Partial; + } + + // Other clients would read the text as HTML and run its lines together. + it('labels an edited description as plain text', async () => { + const data = await save(htmlEvent(), `${TEXT}\nMore`); + expect(data.description).toBe(`${TEXT}\nMore`); + expect(data.descriptionContentType).toBe('text/plain'); + }); + + it('leaves an untouched HTML description as it was', async () => { + const data = await save(htmlEvent()); + expect(data).not.toHaveProperty('description'); + expect(data).not.toHaveProperty('descriptionContentType'); + }); + + // Stalwart writes a set type as STYLED-DESCRIPTION, which many clients skip. + it('sends no type for an event that had none', async () => { + const event = { ...htmlEvent(), description: 'old', descriptionContentType: undefined } as unknown as CalendarEvent; + const data = await save(event, 'new'); + expect(data.description).toBe('new'); + expect(data).not.toHaveProperty('descriptionContentType'); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 621ba7347..6b6595315 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -60,6 +60,7 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; +import { descriptionWithNote } from "@/lib/calendar-description"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/lib/recurrence-scope-edit"; @@ -1532,13 +1533,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleSaveNoteFromDetail = useCallback(async (note: string) => { if (!detailEvent || !client) return; const timestamp = format(displayNow(), "yyyy-MM-dd HH:mm"); - const separator = `\n\n--- ${timestamp} ---\n`; - const newDescription = detailEvent.description - ? `${detailEvent.description}${separator}${note}` - : `--- ${timestamp} ---\n${note}`; + const fields = descriptionWithNote(detailEvent, `--- ${timestamp} ---`, note); try { - if (!(await updateEvent(client, detailEvent.id, { description: newDescription }))) return; - setDetailEvent({ ...detailEvent, description: newDescription }); + if (!(await updateEvent(client, detailEvent.id, fields))) return; + setDetailEvent({ ...detailEvent, ...fields }); toast.success(t("detail.note_saved")); } catch (error) { toast.error(describeError(error, t("notifications.event_error"))); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7e903ab0d..88db9168b 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -9,7 +9,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; -import { descriptionAsText } from "@/lib/calendar-description"; +import { descriptionAsText, isHtmlDescription, plainDescriptionFields } from "@/lib/calendar-description"; import { ParticipantStatusBadge } from "./rsvp-buttons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; @@ -791,8 +791,11 @@ export function EventModal({ // On an existing event the empty string is how a description gets cleared, but // sending it on creation writes a `DESCRIPTION:` with nothing after it — which // invitation e-mails then render as a "Description" heading over blank space. - if (trimmedDescription || event) { - data.description = trimmedDescription; + // An HTML description the user did not touch stays as it was; one they + // edited is saved as the plain text it now is. + const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim(); + if ((trimmedDescription || event) && !keepsHtml) { + Object.assign(data, plainDescriptionFields(event, trimmedDescription)); } if (!event) { @@ -941,7 +944,7 @@ export function EventModal({ savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); // The user's own answer, and the owner's in a calendar shared with them // (lib/calendar-delegate.ts). diff --git a/lib/__tests__/calendar-description.test.ts b/lib/__tests__/calendar-description.test.ts new file mode 100644 index 000000000..76bba7ec0 --- /dev/null +++ b/lib/__tests__/calendar-description.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { descriptionWithNote } from '@/lib/calendar-description'; + +// The quick note in the event popover is added to the description. + +describe('descriptionWithNote', () => { + it('adds a note to an HTML description as an HTML paragraph', () => { + const fields = descriptionWithNote( + { description: '

Agenda

', descriptionContentType: 'text/html' }, + '--- 2026-11-02 10:00 ---', + 'Call & Ann\nabout it', + ); + expect(fields).toEqual({ + description: '

Agenda

--- 2026-11-02 10:00 ---
Call <Bob> & Ann
about it

', + }); + }); + + it('adds a note to a plain description as text', () => { + expect(descriptionWithNote({ description: 'Agenda', descriptionContentType: 'text/plain' }, '--- t ---', 'Call Bob')) + .toEqual({ description: 'Agenda\n\n--- t ---\nCall Bob' }); + expect(descriptionWithNote({ description: '' }, '--- t ---', 'Call Bob')) + .toEqual({ description: '--- t ---\nCall Bob' }); + }); +}); diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts index 03d20d6c3..bf8e3aea0 100644 --- a/lib/__tests__/plugin-host-calendar-tasks.test.ts +++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts @@ -204,6 +204,16 @@ describe('api.calendar', () => { await expect(call(WRITE, 'calendar.updateEvent', 'e1', {})).rejects.toThrow(/patch is empty/); }); + // A plugin writes plain text; left labelled text/html, other clients would + // run its lines together. + it('labels a description written over an HTML one as plain text', async () => { + client.getCalendarEvent.mockResolvedValue(event({ description: '

Old

', descriptionContentType: 'text/html' })); + await call(WRITE, 'calendar.updateEvent', 'e1', { description: 'New\nlines' }); + expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', expect.objectContaining({ + description: 'New\nlines', descriptionContentType: 'text/plain', + }), false, undefined); + }); + it('includes the virtual birthday calendar in listCalendars only when contacts:read is held and showBirthdayCalendar is enabled', async () => { useSettingsStore.setState({ showBirthdayCalendar: true, birthdayCalendarColor: '#f59e0b' }); // Plugin without contacts:read does not see the birthday calendar diff --git a/lib/calendar-description.ts b/lib/calendar-description.ts index 5df68544f..b54e2bbd6 100644 --- a/lib/calendar-description.ts +++ b/lib/calendar-description.ts @@ -1,4 +1,5 @@ import { htmlToPlainText } from '@/lib/html-to-text'; +import { escapeHtml } from '@/lib/email-sanitization'; interface DescribedEvent { description?: string | null; @@ -20,3 +21,38 @@ export function descriptionAsText(event: DescribedEvent): string { if (!description || !isHtmlDescription(event)) return description; return htmlToPlainText(description, { paragraphSpacing: true }); } + +/** + * The fields that write `text` as the description of `event`. An event whose + * description had another type (text/html) is relabelled text/plain, or other + * clients would read the text as HTML and run its lines together. Events + * without a type keep none: Stalwart writes a set type to ICS as + * STYLED-DESCRIPTION, which many CalDAV clients do not read. + */ +export function plainDescriptionFields( + event: DescribedEvent | null | undefined, + text: string, +): { description: string; descriptionContentType?: string } { + const type = event?.descriptionContentType?.trim(); + if (type && !/^text\/plain\b/i.test(type)) return { description: text, descriptionContentType: 'text/plain' }; + return { description: text }; +} + +/** + * The description of `event` with a note added under `heading`. An HTML + * description gets the note as an HTML paragraph, so it keeps its own + * formatting and stays text/html. + */ +export function descriptionWithNote( + event: DescribedEvent, + heading: string, + note: string, +): { description: string; descriptionContentType?: string } { + const description = event.description ?? ''; + if (description && isHtmlDescription(event)) { + const lines = [heading, ...note.split(/\r?\n/)].map(escapeHtml).join('
'); + return { description: `${description}

${lines}

` }; + } + const text = description ? `${description}\n\n${heading}\n${note}` : `${heading}\n${note}`; + return plainDescriptionFields(event, text); +} diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts index ce6d16bf5..fb2cac800 100644 --- a/lib/plugin-sandbox/host-api-calendar.ts +++ b/lib/plugin-sandbox/host-api-calendar.ts @@ -34,6 +34,7 @@ import { expandRecurringEvents } from '../recurrence-expansion'; import { buildParticipantMap } from '../calendar-participants'; import { parseDurationSeconds } from '../calendar-event-normalization'; import { parseAlertOffset, withOwnAlertIds } from '../calendar-alerts'; +import { plainDescriptionFields } from '../calendar-description'; import { patchPath } from '../jmap/patch-object'; import { isValidEmail } from '../validation'; import { getEffectiveTimeZone } from '../timezone'; @@ -537,6 +538,8 @@ export async function doCalendarUpdateEvent(id: unknown, patch: unknown): Promis updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, false)]: true }; } const existing = await eventForMutation(client, method, eventId); + // A plugin writes plain text, also over an HTML description. + if (updates.description !== undefined) Object.assign(updates, plainDescriptionFields(existing, updates.description)); const timeKeys = ['start', 'end', 'duration', 'timeZone', 'isAllDay'] as const; if (timeKeys.some((k) => data[k] !== undefined)) { From f6e0b760d5b344df46267932b301d90e1dc65c19 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:44:41 +0100 Subject: [PATCH 96/99] fix: keep an HTML description's format when duplicating an event --- lib/__tests__/calendar-duplicate.test.ts | 6 ++++++ lib/calendar-duplicate.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/lib/__tests__/calendar-duplicate.test.ts b/lib/__tests__/calendar-duplicate.test.ts index fae3b804d..d6b739f3b 100644 --- a/lib/__tests__/calendar-duplicate.test.ts +++ b/lib/__tests__/calendar-duplicate.test.ts @@ -28,6 +28,12 @@ describe('buildDuplicateEventData', () => { expect(data.virtualLocations).not.toBe(virtualLocations); }); + // Without the type the copy's HTML would be stored, and shown, as text. + it('keeps the format of an HTML description', () => { + const data = buildDuplicateEventData(makeEvent({ description: '

Daily

', descriptionContentType: 'text/html' })); + expect(data).toMatchObject({ description: '

Daily

', descriptionContentType: 'text/html' }); + }); + it('moves a timed event one day later in its own time zone with a fresh uid', () => { const data = buildDuplicateEventData(makeEvent()); expect(data).toMatchObject({ diff --git a/lib/calendar-duplicate.ts b/lib/calendar-duplicate.ts index 19895c9b5..437d08345 100644 --- a/lib/calendar-duplicate.ts +++ b/lib/calendar-duplicate.ts @@ -24,6 +24,8 @@ export function buildDuplicateEventData(event: CalendarEvent): Partial Date: Mon, 2 Nov 2026 23:10:47 +0100 Subject: [PATCH 97/99] fix: expand contact groups into calendar attendees --- .../event-modal-contact-groups.test.tsx | 60 ++++++++++++ .../participant-input-groups.test.tsx | 92 +++++++++++++++++++ components/calendar/participant-input.tsx | 47 +++++++--- 3 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-contact-groups.test.tsx create mode 100644 components/calendar/__tests__/participant-input-groups.test.tsx diff --git a/components/calendar/__tests__/event-modal-contact-groups.test.tsx b/components/calendar/__tests__/event-modal-contact-groups.test.tsx new file mode 100644 index 000000000..35b17b039 --- /dev/null +++ b/components/calendar/__tests__/event-modal-contact-groups.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import { useContactStore } from '@/stores/contact-store'; +import { createDemoContacts } from '@/lib/demo/fixtures/contacts'; +import { createDemoCalendars } from '@/lib/demo/fixtures/calendars'; + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +beforeEach(() => useContactStore.setState({ + contacts: createDemoContacts(), contactsLoaded: true, directoryPrincipals: [], recentRecipients: [], +})); + +async function saveAfterSelecting(queries: string | string[], maxParticipants?: number) { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Issue 1505 synthetic reproduction' } }); + for (const query of typeof queries === 'string' ? [queries] : queries) { + fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: query } }); + const option = await waitFor(() => within(screen.getByRole('listbox')).getAllByRole('option', { name: new RegExp(query) })[0]); + fireEvent.mouseDown(option); + } + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0]; +} + +describe('Issue #1505: actual event editor and save payload', () => { + it('control: individual contact survives selection and reaches saved attendees', async () => { + const [, , metadata] = await saveAfterSelecting('Alice'); + expect(metadata.attendees).toHaveLength(1); + }); + + it('selecting Beacon team includes its five members in the saved attendees', async () => { + const [data, , metadata] = await saveAfterSelecting('Beacon team'); + expect(metadata.attendees.map((a: { email: string }) => a.email)).toEqual([ + 'alice.johnson@meridianlabs.example', 'bob.chen@meridianlabs.example', + 'priya.sharma@meridianlabs.example', 'ahmed.hassan@meridianlabs.example', 'lisa.tanaka@meridianlabs.example', + ]); + expect(Object.values(data.participants)).toHaveLength(6); + }); + + it('keeps an existing attendee once when adding their group', async () => { + const [, , metadata] = await saveAfterSelecting(['Alice', 'Beacon team']); + expect(metadata.attendees).toHaveLength(5); + expect(metadata.attendees.filter((a: { email: string }) => a.email === 'alice.johnson@meridianlabs.example')).toHaveLength(1); + }); + + it('adds two overlapping groups without losing or duplicating attendees', async () => { + const [, , metadata] = await saveAfterSelecting(['Beacon team', 'Leadership']); + expect(metadata.attendees).toHaveLength(7); + expect(new Set(metadata.attendees.map((a: { email: string }) => a.email)).size).toBe(7); + }); + + it('warns when group members plus the organizer exceed the server participant limit', async () => { + await saveAfterSelecting('Beacon team', 5); + expect(screen.getByRole('status')).toHaveTextContent('participants.limit_warning'); + }); +}); diff --git a/components/calendar/__tests__/participant-input-groups.test.tsx b/components/calendar/__tests__/participant-input-groups.test.tsx new file mode 100644 index 000000000..aa30b14de --- /dev/null +++ b/components/calendar/__tests__/participant-input-groups.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ParticipantInput } from '../participant-input'; +import { useContactStore } from '@/stores/contact-store'; +import type { ContactCard } from '@/lib/jmap/types'; +import { createDemoContacts } from '@/lib/demo/fixtures/contacts'; + +beforeEach(() => { + vi.useFakeTimers(); + useContactStore.setState({ contacts: createDemoContacts(), contactsLoaded: true, directoryPrincipals: [], recentRecipients: [] }); +}); + +afterEach(() => vi.useRealTimers()); + +function search(query: string, participants: Array<{ name: string; email: string }> = []) { + const onAdd = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: query } }); + act(() => vi.advanceTimersByTime(200)); + return { input, onAdd }; +} + +describe('Issue #1505: real contact store and real calendar participant input', () => { + it('control: clicking an individual contact adds that attendee', () => { + const { onAdd } = search('Alice'); + fireEvent.mouseDown(screen.getAllByRole('option')[0]); + expect(onAdd).toHaveBeenCalledTimes(1); + expect(onAdd.mock.calls[0][0].email).toContain('@'); + }); + + it('clicking Beacon team adds its five members as attendees', () => { + const { onAdd } = search('Beacon team'); + fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon team/ })); + expect(onAdd).toHaveBeenCalledTimes(5); + expect(screen.getByRole('combobox')).toHaveValue(''); + }); + + it('selecting Beacon team with the keyboard adds its five members', () => { + const { input, onAdd } = search('Beacon team'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onAdd).toHaveBeenCalledTimes(5); + }); + + it('adds the only member of a one-person group', () => { + const contacts = createDemoContacts(); + const member = contacts.find(c => c.id === 'demo-contact-alice')!; + const group = contacts.find(c => c.id === 'demo-group-beacon')!; + useContactStore.setState({ contacts: [member, { ...group, members: { [member.id]: true } }] }); + const { onAdd } = search('Beacon team'); + fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon team/ })); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('marks group suggestions with a translated group label and member count', () => { + search('Beacon team'); + expect(screen.getByTitle('directory.type.group')).toBeInTheDocument(); + expect(screen.getByText('groups.member_count')).toBeInTheDocument(); + }); + + it('adds each usable address once, skipping existing attendees and missing or invalid emails', () => { + const contacts: ContactCard[] = [ + { addressBookIds: { book: true }, id: 'group', kind: 'group', name: { full: 'Test group' }, members: { a: true, b: true, c: true, d: true, e: true, f: true } }, + { addressBookIds: { book: true }, id: 'a', kind: 'individual', name: { full: 'Existing' }, emails: { e: { address: 'EXISTING@example.com' } } }, + { addressBookIds: { book: true }, id: 'b', kind: 'individual', name: { full: 'New' }, emails: { e: { address: ' new@example.com ' } } }, + { addressBookIds: { book: true }, id: 'c', kind: 'individual', name: { full: 'Duplicate' }, emails: { e: { address: 'NEW@example.com' } } }, + { addressBookIds: { book: true }, id: 'd', kind: 'individual', name: { full: 'Missing' } }, + { addressBookIds: { book: true }, id: 'e', kind: 'individual', name: { full: 'Invalid' }, emails: { e: { address: 'not-an-email' } } }, + { addressBookIds: { book: true }, id: 'f', kind: 'individual', name: { full: 'Another' }, emails: { e: { address: 'another@example.com' } } }, + ]; + useContactStore.setState({ contacts }); + const { onAdd } = search('Test group', [{ name: 'Already invited', email: 'existing@example.com' }]); + fireEvent.mouseDown(screen.getByRole('option', { name: /Test group/ })); + expect(onAdd.mock.calls.map(([person]) => person)).toEqual([ + { name: 'New', email: 'new@example.com' }, + { name: 'Another', email: 'another@example.com' }, + ]); + }); + + it('keeps two matching groups selectable as separate suggestions', () => { + const contacts = createDemoContacts(); + const group = contacts.find(c => c.id === 'demo-group-beacon')!; + useContactStore.setState({ contacts: [...contacts, { ...group, id: 'other-group', name: { full: 'Beacon other team' } }] }); + const { onAdd } = search('Beacon'); + expect(screen.getAllByRole('option')).toHaveLength(2); + fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon other team/ })); + expect(onAdd).toHaveBeenCalledTimes(5); + }); + +}); diff --git a/components/calendar/participant-input.tsx b/components/calendar/participant-input.tsx index 37edd7b56..abfe832b5 100644 --- a/components/calendar/participant-input.tsx +++ b/components/calendar/participant-input.tsx @@ -3,16 +3,20 @@ import { useState, useRef, useCallback, useEffect, useLayoutEffect, forwardRef, useImperativeHandle } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; -import { X } from "@/components/icons"; +import { X, Users } from "@/components/icons"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; -import { useContactStore } from "@/stores/contact-store"; +import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; interface Participant { name: string; email: string; } +interface ParticipantSuggestion extends Participant { + group?: { id: string; memberCount: number }; +} + interface ParticipantInputProps { participants: Participant[]; onAdd: (participant: Participant) => void; @@ -30,8 +34,9 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const ParticipantInput = forwardRef(function ParticipantInput({ participants, onAdd, onRemove, disabled, id }, ref) { const t = useTranslations("calendar.participants"); + const tContacts = useTranslations("contacts"); const [query, setQuery] = useState(""); - const [suggestions, setSuggestions] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [activeIndex, setActiveIndex] = useState(-1); const [showSuggestions, setShowSuggestions] = useState(false); const inputRef = useRef(null); @@ -69,7 +74,7 @@ export const ParticipantInput = forwardRef p.email.toLowerCase())); - const filtered = results.filter(r => !existing.has(r.email.toLowerCase())); + const filtered = results.filter(r => r.group || !existing.has(r.email.toLowerCase())); setSuggestions(filtered.slice(0, 8)); setShowSuggestions(filtered.length > 0); setActiveIndex(-1); @@ -87,10 +92,20 @@ export const ParticipantInput = forwardRef { - if (!p.email || !EMAIL_REGEX.test(p.email)) return; - if (participants.some(e => e.email.toLowerCase() === p.email.toLowerCase())) return; - onAdd(p); + const addParticipant = useCallback((p: ParticipantSuggestion) => { + if (!p.group && (!EMAIL_REGEX.test(p.email) || participants.some(e => e.email.toLowerCase() === p.email.toLowerCase()))) return; + const candidates = p.group + ? useContactStore.getState().getGroupMembers(p.group.id).map(contact => ({ + name: getContactDisplayName(contact), + email: getContactPrimaryEmail(contact).trim(), + })) + : [p]; + const existing = new Set(participants.map(participant => participant.email.toLowerCase())); + for (const candidate of candidates) { + if (!EMAIL_REGEX.test(candidate.email) || existing.has(candidate.email.toLowerCase())) continue; + existing.add(candidate.email.toLowerCase()); + onAdd({ name: candidate.name, email: candidate.email }); + } setQuery(""); setSuggestions([]); setShowSuggestions(false); @@ -174,7 +189,7 @@ export const ParticipantInput = forwardRef {suggestions.map((s, i) => (
  • - + {s.group ? ( + + + + ) : ( + + )}
    {s.name || s.email}
    - {s.name && ( + {s.group ? ( +
    + {tContacts("groups.member_count", { count: s.group.memberCount })} +
    + ) : s.name && (
    {s.email}
    )}
    From 27e84b3c93c9c1797436ea7aa2840f94584df4ee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:33:57 +0100 Subject: [PATCH 98/99] fix: show the app name and plugin name in a marketplace plugin's permissions --- .../admin/_pages/marketplace-detail.tsx | 2 +- app/api/admin/marketplace/[slug]/route.ts | 6 +++++- .../marketplace/__tests__/preview.test.ts | 21 +++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx index db20ab2d1..9f528b80f 100644 --- a/app/(main)/admin/_pages/marketplace-detail.tsx +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -693,7 +693,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { title="Permissions" description={manifestPerms.length === 0 ? 'This plugin requests no permissions.' - : `What ${preview ? `v${preview.version}` : 'the plugin'} may do in each user's mailbox.`} + : `What ${ext.name}${preview ? ` ${preview.version}` : ''} may do in each user's mailbox.`} > {manifestPerms.length > 0 && (
      diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index e53a2a545..91fb8509a 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -24,6 +24,8 @@ import { type DirectoryVersion, } from '@/lib/admin/marketplace-directory'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { productName } from '@/lib/product-name'; +import { withAppName } from '@/lib/settings-search'; const MAX_PREVIEW_SOURCE_LEN = 100_000; @@ -173,6 +175,7 @@ export async function GET( const installed = await installedExtensions(); const state = installedStateOf(installed, slug, type); const history = await historyFor(type, slug); + const appName = productName(configManager.get('appName')); return NextResponse.json( { @@ -185,7 +188,8 @@ export async function GET( screenshots: detail.screenshots ?? [], themePreviews: detail.themePreviews ?? [], advisories: detail.advisories ?? [], - permissionDetails: detail.permissions ?? [], + // The directory ships the webmail's permission texts unfilled. + permissionDetails: (detail.permissions ?? []).map((p) => withAppName(p, appName) as typeof p), source: detail.source, createdAt: detail.createdAt ?? null, updatedAt: detail.updatedAt ?? null, diff --git a/app/api/admin/marketplace/__tests__/preview.test.ts b/app/api/admin/marketplace/__tests__/preview.test.ts index ca4d8897b..579aff6e5 100644 --- a/app/api/admin/marketplace/__tests__/preview.test.ts +++ b/app/api/admin/marketplace/__tests__/preview.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; import JSZip from 'jszip'; +const appName = vi.hoisted(() => ({ value: 'Webmail' })); + vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), getClientIP: () => '127.0.0.1', @@ -12,7 +14,7 @@ vi.mock('@/lib/admin/session', () => ({ vi.mock('@/lib/admin/config-manager', () => ({ configManager: { ensureLoaded: vi.fn(async () => {}), - get: vi.fn((key: string) => (key === 'extensionDirectoryUrl' ? 'https://extensions.test' : undefined)), + get: vi.fn((key: string) => (key === 'extensionDirectoryUrl' ? 'https://extensions.test' : key === 'appName' ? appName.value : undefined)), }, })); vi.mock('@/lib/admin/plugin-registry', () => ({ @@ -54,7 +56,9 @@ function directory() { const u = new URL(String(url)); calls.push(u.pathname + u.search); if (u.pathname === '/api/v2/extensions/pretty-dates') { - return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions: versions.slice(0, 2), screenshots: [], themePreviews: [], advisories: [], permissions: [] } }); + return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions: versions.slice(0, 2), screenshots: [], themePreviews: [], advisories: [], permissions: [ + { id: 'http:proxy', title: 'Use accounts you connect', body: "Send requests through {appName}'s server.", highRisk: true }, + ] } }); } if (u.pathname === '/api/v2/extensions/pretty-dates/versions') return Response.json({ data: versions }); const m = /\/versions\/([^/]+)\/bundle$/.exec(u.pathname); @@ -105,6 +109,19 @@ describe('GET /api/admin/marketplace/[slug]', () => { expect(body.preview.version).toBe('1.2.0'); }); + it('fills the app name into the permission texts the directory sends', async () => { + directory(); + appName.value = 'Acme Mail'; + let body = await (await preview()).json(); + expect(body.extension.permissionDetails).toEqual([ + { id: 'http:proxy', title: 'Use accounts you connect', body: "Send requests through Acme Mail's server.", highRisk: true }, + ]); + + appName.value = 'Webmail'; + body = await (await preview()).json(); + expect(body.extension.permissionDetails[0].body).toBe("Send requests through Bulwark's server."); + }); + it('refuses a malformed version instead of forwarding it', async () => { directory(); expect((await preview('?version=..%2F..%2Fetc')).status).toBe(400); From e716f529cdc98931591c9207bf9d4236c1be81c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:29:56 +0100 Subject: [PATCH 99/99] test: match template sync and server-state guard to current behaviour --- components/settings/settings-app.tsx | 4 ++-- lib/__tests__/template-sync.test.ts | 15 ++++++++++++--- lib/settings-search.ts | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index ccda165a0..22104668f 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -83,7 +83,7 @@ import { settingsSubResults, tabKeywords, tabSearchEntries, - archiveActionSearchPaths, + ARCHIVE_ACTION_SEARCH_PATHS, } from '@/lib/settings-search'; import { useAccountStore } from '@/stores/account-store'; import { useMediaQuery, atLeast } from '@/hooks/use-media-query'; @@ -336,7 +336,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const subs: Partial> = {}; // Only rows the page draws right now, so every result lands on one. const shown = (entry: SettingsSearchEntry) => { - if (!archiveActionEnabled && archiveActionSearchPaths.has(entry.label)) return false; + if (!archiveActionEnabled && ARCHIVE_ACTION_SEARCH_PATHS.has(entry.label)) return false; if (entry.when === 'touch') return touchScreen; if (entry.when === 'mountainView') return mountainViewLayout; if (entry.when === 'multiAccount') return multiAccount; diff --git a/lib/__tests__/template-sync.test.ts b/lib/__tests__/template-sync.test.ts index 7d0a0dbc0..7759cdcfc 100644 --- a/lib/__tests__/template-sync.test.ts +++ b/lib/__tests__/template-sync.test.ts @@ -49,12 +49,21 @@ describe('parseSyncedTemplates', () => { expect(result!.map((t) => t.id)).toEqual(['a']); }); - it('strips markup from non-HTML fields', () => { + // Name and a plain body are text shown as text, so markup in them is kept as typed. + it('keeps markup in non-HTML fields as typed', () => { const result = parseSyncedTemplates([ makeTemplate({ id: 'a', name: 'Bold name', body: '

      hi

      ' }), ]); - expect(result![0].name).toBe('Bold name'); - expect(result![0].body).toBe('hi'); + expect(result![0].name).toBe('Bold name'); + expect(result![0].body).toBe('

      hi

      '); + }); + + it('sanitizes the body of an HTML template', () => { + const result = parseSyncedTemplates([ + makeTemplate({ id: 'a', isHTML: true, body: '

      hi

      ' }), + ]); + expect(result![0].body).toContain('

      hi

      '); + expect(result![0].body).not.toContain('onerror'); }); }); diff --git a/lib/settings-search.ts b/lib/settings-search.ts index cf7529696..c3924f6d7 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -369,7 +369,7 @@ export const tabSearchEntries: Record = { // Extra English keywords per tab so common search terms hit even when the // translation doesn't contain the literal word. /** Settings shown only while the admin has the Archive action on (#1257). */ -export const archiveActionSearchPaths: ReadonlySet = new Set(['settings.email_behavior.archive_mode.label']); +export const ARCHIVE_ACTION_SEARCH_PATHS: ReadonlySet = new Set(['settings.email_behavior.archive_mode.label']); export const tabKeywords: Record = { account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group',