From 19a522a4a0c0f0cfd30b0056654863af4967c68b Mon Sep 17 00:00:00 2001
From: Tobias Genannt
Date: Sat, 31 Oct 2026 19:06:20 +0100
Subject: [PATCH 01/21] feat: expose contact birthday calendar and events in
plugin calendar api
- Include virtual birthday calendar in api.calendar.listCalendars when showBirthdayCalendar is enabled and plugin holds contacts:read
- Merge contact birthday events into api.calendar.getEvents and support single event retrieval in api.calendar.getEvent when plugin holds contacts:read
- Use same start and end date range bounds as regular events in getEvents
- Guard against mutating birthday calendar or events via createEvent, updateEvent, or deleteEvent
- Emit onCalendarChange when showBirthdayCalendar setting changes or when contacts are modified
- Add unit tests for birthday calendar listing, querying, filtering, permission checks, and mutation guards
---
.../plugin-host-calendar-tasks.test.ts | 95 ++++++++++++++++++-
lib/plugin-sandbox/host-api-calendar.ts | 76 +++++++++++++--
lib/plugin-sandbox/host-api.ts | 6 +-
stores/contact-store.ts | 27 +++++-
stores/settings-store.ts | 10 +-
5 files changed, 200 insertions(+), 14 deletions(-)
diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts
index 2ae3d3d97..a050a9a40 100644
--- a/lib/__tests__/plugin-host-calendar-tasks.test.ts
+++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts
@@ -7,6 +7,8 @@ import { useCalendarStore } from '@/stores/calendar-store';
import { useTaskStore } from '@/stores/task-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
+import { useContactStore } from '@/stores/contact-store';
+import { BIRTHDAY_CALENDAR_ID, BIRTHDAY_CALENDAR_COLOR } from '@/lib/birthday-calendar';
import { calendarHooks, taskHooks } from '../plugin-hooks';
vi.mock('@/stores/toast-store', () => ({
@@ -24,7 +26,9 @@ function plugin(perms: string[]): InstalledPlugin {
}
const READ = plugin(['calendar:read', 'tasks:read']);
+const READ_WITH_CONTACTS = plugin(['calendar:read', 'tasks:read', 'contacts:read']);
const WRITE = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write']);
+const WRITE_WITH_CONTACTS = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write', 'contacts:read']);
const NONE = plugin([]);
const work = { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false } as Calendar;
@@ -73,7 +77,8 @@ beforeEach(() => {
identities: [{ id: 'id1', name: 'Ann', email: 'ann@example.com' }] as never,
preferredPrimaryId: null,
});
- useSettingsStore.setState({ timeZone: 'Europe/Berlin' } as never);
+ useSettingsStore.setState({ timeZone: 'Europe/Berlin', showBirthdayCalendar: false, birthdayCalendarColor: BIRTHDAY_CALENDAR_COLOR } as never);
+ useContactStore.setState({ contacts: [], contactsLoaded: true });
});
afterEach(() => {
@@ -81,6 +86,8 @@ afterEach(() => {
useCalendarStore.setState({ calendars: [], events: [] });
useTaskStore.setState({ tasks: [] });
useIdentityStore.setState({ identities: [] });
+ useSettingsStore.setState({ showBirthdayCalendar: false });
+ useContactStore.setState({ contacts: [] });
calendarHooks.onBeforeEventCreate.removePlugin('veto');
taskHooks.onBeforeTaskDelete.removePlugin('veto');
});
@@ -196,6 +203,92 @@ describe('api.calendar', () => {
await expect(call(WRITE, 'calendar.deleteEvent', 'missing')).rejects.toThrow(/no event with id "missing"/);
await expect(call(WRITE, 'calendar.updateEvent', 'e1', {})).rejects.toThrow(/patch is empty/);
});
+
+ 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
+ const calsWithoutContacts = await call(READ, 'calendar.listCalendars') as { id: string }[];
+ expect(calsWithoutContacts.some((c) => c.id === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // Plugin with contacts:read sees the birthday calendar
+ const cals = await call(READ_WITH_CONTACTS, 'calendar.listCalendars') as { id: string; name: string; color: string }[];
+ expect(cals).toEqual([
+ { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false },
+ { id: 'cal-todo', name: 'To do', color: '', isVisible: true, isDefault: false, isTasksOnly: true },
+ { id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays', color: '#f59e0b', isVisible: true, isDefault: false, isTasksOnly: false },
+ ]);
+ });
+
+ it('returns contact birthdays in getEvents only when contacts:read is held and showBirthdayCalendar is enabled', async () => {
+ useSettingsStore.setState({ showBirthdayCalendar: true });
+ useContactStore.setState({
+ contacts: [
+ {
+ id: 'c1',
+ name: { full: 'Alice Smith' },
+ anniversaries: { b1: { kind: 'birth', date: '1990-05-15' } },
+ } as never,
+ ],
+ });
+
+ // Plugin without contacts:read does not see birthday events
+ const eventsWithoutContacts = await call(READ, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as { id: string; calendarId: string }[];
+ expect(eventsWithoutContacts.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // Plugin with contacts:read sees birthday events
+ const events = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as {
+ id: string; title: string; calendarId: string; isAllDay: boolean; start: string;
+ }[];
+
+ const birthday = events.find((e) => e.calendarId === BIRTHDAY_CALENDAR_ID);
+ expect(birthday).toBeDefined();
+ expect(birthday).toEqual(expect.objectContaining({
+ id: 'birthday-c1-b1-2026',
+ title: '🎂 Alice Smith (36)',
+ calendarId: BIRTHDAY_CALENDAR_ID,
+ isAllDay: true,
+ start: '2026-05-15T00:00:00',
+ }));
+
+ // Respects date range bounds using same start/end as regular events
+ const beforeBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-01', to: '2026-05-10',
+ }) as { calendarId: string }[];
+ expect(beforeBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ const afterBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-20', to: '2026-05-31',
+ }) as { calendarId: string }[];
+ expect(afterBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // Respects calendarIds filtering
+ const onlyWork = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-01', to: '2026-06-01', calendarIds: ['cal-work'],
+ }) as { calendarId: string }[];
+ expect(onlyWork.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ const onlyBirthdays = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-01', to: '2026-06-01', calendarIds: [BIRTHDAY_CALENDAR_ID],
+ }) as { calendarId: string }[];
+ expect(onlyBirthdays.every((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(true);
+
+ // Fetching a birthday event by ID requires contacts:read
+ expect(await call(READ, 'calendar.getEvent', 'birthday-c1-b1-2026')).toBeNull();
+ const single = await call(READ_WITH_CONTACTS, 'calendar.getEvent', 'birthday-c1-b1-2026');
+ expect(single).toEqual(expect.objectContaining({
+ id: 'birthday-c1-b1-2026',
+ title: '🎂 Alice Smith (36)',
+ }));
+
+ // Birthday events cannot be mutated
+ await expect(call(WRITE_WITH_CONTACTS, 'calendar.updateEvent', 'birthday-c1-b1-2026', { title: 'New' }))
+ .rejects.toThrow(/birthday events cannot be modified/);
+ await expect(call(WRITE_WITH_CONTACTS, 'calendar.deleteEvent', 'birthday-c1-b1-2026'))
+ .rejects.toThrow(/birthday events cannot be modified/);
+ await expect(call(WRITE_WITH_CONTACTS, 'calendar.createEvent', {
+ title: 'New', calendarId: BIRTHDAY_CALENDAR_ID, start: '2026-05-15T10:00', end: '2026-05-15T11:00',
+ })).rejects.toThrow(/no calendar with id "__birthday-calendar__"/);
+ });
});
describe('api.tasks', () => {
diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts
index fcaf989b7..2b6733fba 100644
--- a/lib/plugin-sandbox/host-api-calendar.ts
+++ b/lib/plugin-sandbox/host-api-calendar.ts
@@ -8,16 +8,26 @@
import { createRecurrenceRule } from '@/lib/recurrence-rule';
import type {
- CalendarEventInput, CalendarEventView, CalendarView, TaskInput, TaskView,
+ CalendarEventInput, CalendarEventView, CalendarView, InstalledPlugin, TaskInput, TaskView,
} from '../plugin-types';
import type {
Calendar, CalendarEvent, CalendarEventAlert, CalendarLink, CalendarParticipant, CalendarRecurrenceRule,
- CalendarRelation, CalendarTask,
+ CalendarRelation, CalendarTask, ContactCard,
} from '../jmap/types';
import type { IJMAPClient } from '../jmap/client-interface';
import { useCalendarStore } from '@/stores/calendar-store';
import { useTaskStore } from '@/stores/task-store';
import { useIdentityStore } from '@/stores/identity-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useContactStore } from '@/stores/contact-store';
+import {
+ BIRTHDAY_CALENDAR_ID,
+ createBirthdayCalendar,
+ generateBirthdayEvents,
+} from '@/lib/birthday-calendar';
+import { getEffectiveLocale } from '@/i18n/detect-locale';
+import { messagesFor } from './host-api-app';
+import { pluginHasPermission } from './permissions';
import { eventToView, taskToView } from '../plugin-projection';
import { expandRecurringEvents } from '../recurrence-expansion';
import { buildParticipantMap } from '../calendar-participants';
@@ -221,9 +231,39 @@ async function pickCalendar(
return choice.id;
}
-export async function doCalendarListCalendars(): Promise {
+async function birthdayCalendarName(): Promise {
+ try {
+ const locale = getEffectiveLocale();
+ const msgs = await messagesFor(locale);
+ if (typeof msgs?.birthday_calendar === 'string') return msgs.birthday_calendar;
+ } catch {
+ // fallback
+ }
+ return 'Birthdays';
+}
+
+async function ensureContacts(client: IJMAPClient): Promise {
+ const store = useContactStore.getState();
+ if (!store.contactsLoaded && store.contacts.length === 0 && client?.supportsContacts?.()) {
+ try {
+ await store.fetchContacts(client);
+ } catch {
+ // Ignore network errors; proceed with current contacts
+ }
+ }
+ return useContactStore.getState().contacts;
+}
+
+export async function doCalendarListCalendars(plugin?: InstalledPlugin): Promise {
const client = sessionClient('calendar.listCalendars');
- return (await ensureCalendars(client)).map(calendarToView);
+ const calendars = (await ensureCalendars(client)).map(calendarToView);
+ const settings = useSettingsStore.getState();
+ const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false;
+ if (settings.showBirthdayCalendar && canReadContacts) {
+ const name = await birthdayCalendarName();
+ calendars.push(calendarToView(createBirthdayCalendar(name, settings.birthdayCalendarColor)));
+ }
+ return calendars;
}
// ─── Reading events ───────────────────────────────────────────
@@ -233,7 +273,7 @@ function rangeBound(when: WhenParts): string {
return `${when.date}T${when.time ?? '00:00:00'}${when.utc ? 'Z' : ''}`;
}
-export async function doCalendarGetEvents(options: unknown): Promise {
+export async function doCalendarGetEvents(options: unknown, plugin?: InstalledPlugin): Promise {
const method = 'calendar.getEvents';
const opts = readObject(options, method, 'options', ['from', 'to', 'calendarIds']);
const from = readWhen(opts.from, method, 'from');
@@ -260,6 +300,15 @@ export async function doCalendarGetEvents(options: unknown): Promise typeof e.start === 'string' && e.start && !Number.isNaN(Date.parse(e.start)));
const events = expandRecurringEvents(valid, start, end)
.filter((e) => !calendarIds || Object.keys(e.calendarIds || {}).some((id) => e.calendarIds[id] && calendarIds.has(id)));
+
+ const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false;
+ const { showBirthdayCalendar } = useSettingsStore.getState();
+ if (showBirthdayCalendar && canReadContacts && (!calendarIds || calendarIds.has(BIRTHDAY_CALENDAR_ID))) {
+ const contacts = await ensureContacts(client);
+ const birthdays = generateBirthdayEvents(contacts, start, end);
+ events.push(...birthdays);
+ }
+
const sortKey = (e: CalendarEvent) => e.utcStart ?? e.start;
events.sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : sortKey(a) > sortKey(b) ? 1 : 0));
return events.slice(0, MAX_PLUGIN_EVENTS).map(eventToView);
@@ -294,9 +343,21 @@ async function loadEvent(client: IJMAPClient, id: string): Promise {
+export async function doCalendarGetEvent(id: unknown, plugin?: InstalledPlugin): Promise {
const method = 'calendar.getEvent';
const eventId = readId(id, method);
+ if (eventId.startsWith('birthday-')) {
+ const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false;
+ const { showBirthdayCalendar } = useSettingsStore.getState();
+ if (!showBirthdayCalendar || !canReadContacts) return null;
+ const client = sessionClient(method);
+ const contacts = await ensureContacts(client);
+ const match = eventId.match(/^birthday-(.+)-(\d{4})$/);
+ const year = match ? parseInt(match[2], 10) : new Date().getFullYear();
+ const birthdays = generateBirthdayEvents(contacts, `${year}-01-01T00:00:00`, `${year}-12-31T23:59:59`);
+ const found = birthdays.find((b) => b.id === eventId);
+ return found ? eventToView(found) : null;
+ }
const event = await loadEvent(sessionClient(method), eventId);
return event ? eventToView(event) : null;
}
@@ -307,6 +368,9 @@ export async function doCalendarGetEvent(id: unknown): Promise {
+ if (id.startsWith('birthday-')) {
+ throw new Error(`${method}: birthday events cannot be modified`);
+ }
const event = await loadEvent(client, id);
if (!event) throw new Error(`${method}: no event with id "${id}"`);
if (!useCalendarStore.getState().events.some((e) => e.id === id)) {
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 76e9a50e7..e482ce0d7 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -2307,9 +2307,9 @@ export async function dispatchApiCall(
return undefined;
}
- case 'calendar.listCalendars': return doCalendarListCalendars();
- case 'calendar.getEvents': return doCalendarGetEvents(args[0]);
- case 'calendar.getEvent': return doCalendarGetEvent(args[0]);
+ case 'calendar.listCalendars': return doCalendarListCalendars(plugin);
+ case 'calendar.getEvents': return doCalendarGetEvents(args[0], plugin);
+ case 'calendar.getEvent': return doCalendarGetEvent(args[0], plugin);
case 'calendar.createEvent': return doCalendarCreateEvent(args[0], plugin.id);
case 'calendar.updateEvent': return doCalendarUpdateEvent(args[0], args[1]);
case 'calendar.deleteEvent': return doCalendarDeleteEvent(args[0]);
diff --git a/stores/contact-store.ts b/stores/contact-store.ts
index f6c8d84ab..cd10a91f7 100644
--- a/stores/contact-store.ts
+++ b/stores/contact-store.ts
@@ -5,11 +5,12 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { loadPrincipalDirectory } from '@/lib/jmap/principal-directory';
import { generateUUID } from '@/lib/utils';
import { debug } from '@/lib/debug';
-import { settingsHooks } from '@/lib/plugin-hooks';
+import { calendarHooks, contactHooks, settingsHooks } from '@/lib/plugin-hooks';
+import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
+import { useSettingsStore } from './settings-store';
import { getClientByLocalAccountId } from './client-registry';
import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox';
import { inlineContactMedia, writeContactWithNewPhoto, type ContactSaveOptions } from '@/lib/contact-media';
-import { contactHooks } from '@/lib/plugin-hooks';
import { contactToView } from '@/lib/plugin-projection';
import { PrincipalsRequestError } from '@/lib/jmap/principals-error';
import type { Principal } from '@/lib/jmap/types';
@@ -20,6 +21,16 @@ function draftContactView(contact: Partial) {
return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard);
}
+function notifyBirthdayCalendarIfEnabled() {
+ try {
+ if (useSettingsStore.getState().showBirthdayCalendar) {
+ void calendarHooks.onCalendarChange.emit({ calendarId: BIRTHDAY_CALENDAR_ID, change: 'updated' });
+ }
+ } catch {
+ // Ignore when stores not initialized in unit tests
+ }
+}
+
function sameIdSet(a: Set, b: Set): boolean {
if (a.size !== b.size) return false;
for (const id of a) if (!b.has(id)) return false;
@@ -749,6 +760,7 @@ export const useContactStore = create()(
isLoading: false,
}));
void contactHooks.onAfterContactCreate.emit(contactToView(stored));
+ notifyBirthdayCalendarIfEnabled();
return true;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to create contact';
@@ -788,6 +800,7 @@ export const useContactStore = create()(
}));
const updated = get().contacts.find(c => c.id === id);
if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated));
+ notifyBirthdayCalendarIfEnabled();
return true;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to update contact';
@@ -821,6 +834,7 @@ export const useContactStore = create()(
};
});
void contactHooks.onAfterContactDelete.emit({ contactIds: [id] });
+ notifyBirthdayCalendarIfEnabled();
return true;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
@@ -833,6 +847,7 @@ export const useContactStore = create()(
if (!(await contactHooks.onBeforeContactCreate.intercept(draftContactView(contact)))) return false;
set((state) => ({ contacts: [...state.contacts, contact] }));
void contactHooks.onAfterContactCreate.emit(contactToView(contact));
+ notifyBirthdayCalendarIfEnabled();
return true;
},
@@ -845,6 +860,7 @@ export const useContactStore = create()(
}));
const updated = get().contacts.find(c => c.id === id);
if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated));
+ notifyBirthdayCalendarIfEnabled();
return true;
},
@@ -859,6 +875,7 @@ export const useContactStore = create()(
};
});
void contactHooks.onAfterContactDelete.emit({ contactIds: [id] });
+ notifyBirthdayCalendarIfEnabled();
return true;
},
@@ -1202,6 +1219,7 @@ export const useContactStore = create()(
});
if (deletedIds.size > 0) {
void contactHooks.onAfterContactDelete.emit({ contactIds: ids.filter(id => deletedIds.has(id)) });
+ notifyBirthdayCalendarIfEnabled();
}
return true;
},
@@ -1616,7 +1634,10 @@ export const useContactStore = create()(
}
}
- if (imported > 0) void contactHooks.onContactsImport.emit({ addressBookId, imported });
+ if (imported > 0) {
+ void contactHooks.onContactsImport.emit({ addressBookId, imported });
+ notifyBirthdayCalendarIfEnabled();
+ }
return imported;
},
});
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 2427a5965..ba8a512c3 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -9,7 +9,8 @@ import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '@/lib/notification-mutes';
import { apiFetch } from '@/lib/browser-navigation';
import { writePushPreferences } from '@/lib/push-preferences';
-import { settingsHooks } from '@/lib/plugin-hooks';
+import { calendarHooks, settingsHooks } from '@/lib/plugin-hooks';
+import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
import { generateAccountId } from '@/lib/account-utils';
import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync';
import type { FolderCountMode } from '@/lib/folder-counts';
@@ -1310,6 +1311,13 @@ export const useSettingsStore = create()(
applyInterfaceLayout(value as InterfaceLayout, get().density);
}
+ // Notify calendar plugins (e.g. calendar-agenda) when the virtual birthday calendar is toggled or recolored
+ if (key === 'showBirthdayCalendar' || key === 'birthdayCalendarColor') {
+ void calendarHooks.onCalendarChange.emit({
+ calendarId: BIRTHDAY_CALENDAR_ID,
+ change: key === 'showBirthdayCalendar' && !value ? 'removed' : 'updated',
+ });
+ }
};
const buildExport = (opts?: { forSync?: boolean; subscriptionOwner?: string }): string => {
From 9050724fd1336d0bf0d23855ceb940d6c6150a7f Mon Sep 17 00:00:00 2001
From: Jacob Zeitz
Date: Sat, 31 Oct 2026 07:44:18 +0100
Subject: [PATCH 02/21] feat(plugins): task reminders reach plugins, which may
show a reminder themselves
onCalendarAlert now fires for a task's display alert too, as
{ id, taskId, taskTitle, triggerTime }, and closing that reminder's toast
fires onCalendarAlertAcknowledge with { alertId, taskId }. A task's
reminder reaches only plugins with tasks:read, an event's only plugins
with calendar:read; either permission registers the hooks, and the host
hands each call only to a plugin holding the one it needs.
onBeforeCalendarAlert, a new intercept, runs before Bulwark shows a
reminder of an event or a task, with the same payload. A plugin that
shows reminders itself returns false: Bulwark then plays no sound, sets
no title alert and shows no toast for that reminder, while
onCalendarAlert still fires. Like the reminder it runs in every open
tab, and each tab follows its own answer. It fails open within five
seconds, so a hung plugin delays a reminder but never drops it. Without
a handler the reminder shows at once, as before.
---
.../use-calendar-alerts-hooks.test.tsx | 117 +++++++++++++++++-
hooks/use-calendar-alerts.ts | 98 +++++++++------
lib/__tests__/plugin-hook-permissions.test.ts | 45 ++++++-
lib/__tests__/plugin-hooks.test.ts | 19 +++
.../plugin-permission-coverage.test.ts | 5 +-
lib/plugin-hook-catalog.ts | 20 ++-
lib/plugin-hooks.ts | 14 ++-
lib/plugin-sandbox/loader.ts | 12 +-
lib/plugin-sandbox/permissions.ts | 16 ++-
lib/plugin-types.ts | 20 ++-
10 files changed, 311 insertions(+), 55 deletions(-)
diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
index 9a17672d9..331953d64 100644
--- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
+++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
@@ -8,7 +8,12 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
-import type { CalendarEvent } from '@/lib/jmap/types';
+import { useTaskStore } from '@/stores/task-store';
+import type { CalendarEvent, CalendarTask } from '@/lib/jmap/types';
+
+const sounds = vi.hoisted(() => ({ play: vi.fn(), title: vi.fn() }));
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play }));
+vi.mock('@/lib/title-alert', () => ({ startTitleAlert: sounds.title }));
// A reminder that fires tells plugins (onCalendarAlert); the user closing
// its toast tells them it was dismissed (onCalendarAlertAcknowledge). A
@@ -30,6 +35,21 @@ const event = {
},
} as unknown as CalendarEvent;
+const task = {
+ id: 't1',
+ '@type': 'Task',
+ uid: 'uid-t1',
+ title: 'Call back Anna',
+ calendarIds: { c1: true },
+ due: new Date(Date.now() + 60 * 60_000).toISOString().slice(0, 19),
+ start: null,
+ progress: 'needs-action',
+ useDefaultAlerts: false,
+ alerts: {
+ 'follow-up': { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: fireTime } },
+ },
+} as unknown as CalendarTask;
+
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -41,12 +61,19 @@ beforeEach(() => {
useToastStore.setState({ toasts: [] });
useCalendarNotificationStore.getState().clearAll();
useAuthStore.setState({ isAuthenticated: true, client: null });
- useSettingsStore.setState({ calendarNotificationsEnabled: true, calendarNotificationSound: false, enableCalendarTasks: false });
+ useSettingsStore.setState({
+ calendarNotificationsEnabled: true, calendarNotificationSound: false, enableCalendarTasks: false,
+ doNotDisturb: false, visualAlerts: false,
+ });
useCalendarStore.setState({ events: [event], calendars: [] });
+ useTaskStore.setState({ tasks: [] });
+ sounds.play.mockClear();
+ sounds.title.mockClear();
});
afterEach(() => {
vi.useRealTimers();
+ vi.restoreAllMocks();
});
describe('reminder hooks', () => {
@@ -76,6 +103,92 @@ describe('reminder hooks', () => {
});
});
+describe('a task reminder', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ enableCalendarTasks: true });
+ useCalendarStore.setState({ events: [] });
+ useTaskStore.setState({ tasks: [task] });
+ });
+
+ it('reaches plugins with its task id, and its dismissal too', async () => {
+ const fired = vi.fn();
+ const acknowledged = vi.fn();
+ calendarHooks.onCalendarAlert.register('t', fired);
+ calendarHooks.onCalendarAlertAcknowledge.register('t', acknowledged);
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(fired).toHaveBeenCalledTimes(1));
+ expect(fired).toHaveBeenCalledWith({
+ id: 'follow-up', taskId: 't1', taskTitle: 'Call back Anna', triggerTime: new Date(fireTime).toISOString(),
+ });
+
+ const close = screen.getByText('Call back Anna').closest('.toast-item')!.querySelector('button:last-of-type')!;
+ fireEvent.click(close);
+ expect(acknowledged).toHaveBeenCalledWith({ alertId: 'follow-up', taskId: 't1' });
+ });
+});
+
+describe('a plugin that shows the reminder itself', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ calendarNotificationSound: true, visualAlerts: true, enableCalendarTasks: true });
+ useTaskStore.setState({ tasks: [task] });
+ });
+
+ it('takes over a task reminder: no toast, sound or title alert, the event reminder as before', async () => {
+ const fired = vi.fn();
+ const before = vi.fn((alert: { taskId?: string }) => (alert.taskId ? false : undefined));
+ calendarHooks.onCalendarAlert.register('t', fired);
+ calendarHooks.onBeforeCalendarAlert.register('t', before);
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(screen.queryByText('Dentist')).not.toBeNull());
+ expect(before).toHaveBeenCalledWith({
+ id: 'follow-up', taskId: 't1', taskTitle: 'Call back Anna', triggerTime: new Date(fireTime).toISOString(),
+ });
+ expect(before).toHaveBeenCalledWith({
+ id: 'a1', eventId: 'e1', eventTitle: 'Dentist', triggerTime: new Date(fireTime).toISOString(),
+ });
+ // Both still reported as fired, so the plugin knows.
+ expect(fired).toHaveBeenCalledTimes(2);
+ await act(async () => { await Promise.resolve(); });
+ expect(screen.queryByText('Call back Anna')).toBeNull();
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ expect(sounds.title).toHaveBeenCalledTimes(1);
+ });
+
+ it('takes over an event reminder too', async () => {
+ useTaskStore.setState({ tasks: [] });
+ const before = vi.fn(() => false);
+ calendarHooks.onBeforeCalendarAlert.register('t', before);
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(before).toHaveBeenCalledTimes(1));
+ await act(async () => { await Promise.resolve(); });
+ expect(screen.queryByText('Dentist')).toBeNull();
+ expect(sounds.play).not.toHaveBeenCalled();
+ expect(sounds.title).not.toHaveBeenCalled();
+ });
+
+ it('leaves the reminder to Bulwark when the handler fails', async () => {
+ useTaskStore.setState({ tasks: [] });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ calendarHooks.onBeforeCalendarAlert.register('t', () => { throw new Error('broken'); });
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(screen.queryByText('Dentist')).not.toBeNull());
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ expect(sounds.title).toHaveBeenCalledTimes(1);
+ });
+});
+
describe('toast onDismiss', () => {
it('runs when the user closes the toast, not when it times out', () => {
vi.useFakeTimers();
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
index 4735ef116..7915de752 100644
--- a/hooks/use-calendar-alerts.ts
+++ b/hooks/use-calendar-alerts.ts
@@ -15,6 +15,7 @@ import { lastingToastDuration } from '@/lib/toast-duration';
import { startTitleAlert } from '@/lib/title-alert';
import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
+import type { CalendarAlertView } from '@/lib/plugin-types';
import { calendarHooks } from '@/lib/plugin-hooks';
import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events';
@@ -53,6 +54,23 @@ function reminderTitleAlert(text: string): void {
if (useSettingsStore.getState().visualAlerts) startTitleAlert(text);
}
+/**
+ * Tells plugins a reminder fired, then shows it unless a plugin takes it
+ * over (onBeforeCalendarAlert returning false), as one that shows reminders
+ * itself does. onCalendarAlert fires either way. With no plugin asked, the
+ * reminder is shown at once.
+ */
+function remind(view: CalendarAlertView, show: () => void): void {
+ void calendarHooks.onCalendarAlert.emit(view);
+ if (calendarHooks.onBeforeCalendarAlert.size === 0) {
+ show();
+ return;
+ }
+ void calendarHooks.onBeforeCalendarAlert.intercept(view).then((allowed) => {
+ if (allowed) show();
+ });
+}
+
export function useCalendarAlerts() {
const { isAuthenticated, client } = useAuthStore();
const { events, calendars, supportsCalendar } = useCalendarStore();
@@ -95,20 +113,9 @@ export function useCalendarAlerts() {
shownKeysRef.current.add(key);
acknowledgeAlert(key, alert.fireTimeMs);
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
-
const diffMs = (alert.event.utcStart ? new Date(alert.event.utcStart).getTime() : parseISO(alert.event.start).getTime()) - now;
const diffMin = Math.round(diffMs / 60000);
- void calendarHooks.onCalendarAlert.emit({
- id: alert.alertId,
- eventId: alert.eventId,
- eventTitle: alert.event.title || '',
- triggerTime: new Date(alert.fireTimeMs).toISOString(),
- });
-
const timeLabel = diffMin <= 0
? t('alert_now')
: t('alert_in_minutes', { count: diffMin });
@@ -117,18 +124,28 @@ export function useCalendarAlerts() {
? `${timeLabel} · ${alert.calendarName}`
: timeLabel;
- reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: alert.event.title || t('alert_title'),
- message,
- duration: reminderToastDuration(),
- onClick: () => {
- window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
- },
- onDismiss: () => {
- void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
- },
+ remind({
+ id: alert.alertId,
+ eventId: alert.eventId,
+ eventTitle: alert.event.title || '',
+ triggerTime: new Date(alert.fireTimeMs).toISOString(),
+ }, () => {
+ if (reminderSoundOn(calendarNotificationSound)) {
+ playNotificationSound(notificationSoundChoice);
+ }
+ reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
+ addToast({
+ type: 'info',
+ title: alert.event.title || t('alert_title'),
+ message,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
+ },
+ });
});
}
@@ -142,23 +159,32 @@ export function useCalendarAlerts() {
shownKeysRef.current.add(key);
acknowledgeAlert(key, taskAlert.fireTimeMs);
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
-
const taskMsg = taskAlert.calendarName
? `${t('task_due')} · ${taskAlert.calendarName}`
: t('task_due');
- reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: taskAlert.task.title || t('alert_title'),
- message: taskMsg,
- duration: reminderToastDuration(),
- onClick: () => {
- window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
- },
+ remind({
+ id: taskAlert.alertId,
+ taskId: taskAlert.taskId,
+ taskTitle: taskAlert.task.title || '',
+ triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
+ }, () => {
+ if (reminderSoundOn(calendarNotificationSound)) {
+ playNotificationSound(notificationSoundChoice);
+ }
+ reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
+ addToast({
+ type: 'info',
+ title: taskAlert.task.title || t('alert_title'),
+ message: taskMsg,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
+ },
+ });
});
}
}
diff --git a/lib/__tests__/plugin-hook-permissions.test.ts b/lib/__tests__/plugin-hook-permissions.test.ts
index 4f6557044..be4dd710a 100644
--- a/lib/__tests__/plugin-hook-permissions.test.ts
+++ b/lib/__tests__/plugin-hook-permissions.test.ts
@@ -43,7 +43,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
import { get as getActive } from '../plugin-sandbox/registry';
-import { emailHooks, renderHooks, appLifecycleHooks } from '../plugin-hooks';
+import { emailHooks, renderHooks, appLifecycleHooks, calendarHooks } from '../plugin-hooks';
import { hookPermission, mayOfferSlot, mayRegisterHook, pluginHasPermission } from '../plugin-sandbox/permissions';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '../jmap/types';
@@ -143,6 +143,49 @@ describe('a message that is already open', () => {
});
});
+describe('reminders', () => {
+ const eventAlert = { id: 'a1', eventId: 'e1', eventTitle: 'Dentist', triggerTime: '2026-10-31T08:00:00.000Z' };
+ const taskAlert = { id: 'follow-up', taskId: 't1', taskTitle: 'Call back Anna', triggerTime: '2026-10-31T08:00:00.000Z' };
+ const reminderHooks = ['onBeforeCalendarAlert', 'onCalendarAlert', 'onCalendarAlertAcknowledge'];
+
+ function calls(): unknown[][] {
+ return (mocks.invokeHook as ReturnType).mock.calls;
+ }
+
+ it.each(reminderHooks)('%s may be registered with calendar:read or tasks:read', (hook) => {
+ expect(mayRegisterHook(plugin({ permissions: ['calendar:read'] }), hook)).toBe(true);
+ expect(mayRegisterHook(plugin({ permissions: ['tasks:read'] }), hook)).toBe(true);
+ expect(mayRegisterHook(plugin({ permissions: ['email:read'] }), hook)).toBe(false);
+ });
+
+ it("hands a plugin with tasks:read a task's reminder only", async () => {
+ mocks.background.hooks = reminderHooks;
+ await loadSandboxedPlugin(plugin({ permissions: ['tasks:read'] }));
+
+ await calendarHooks.onCalendarAlert.emit(eventAlert);
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(eventAlert)).toBe(true);
+ await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'a1', eventId: 'e1' });
+ expect(calls()).toHaveLength(0);
+
+ await calendarHooks.onCalendarAlert.emit(taskAlert);
+ await calendarHooks.onBeforeCalendarAlert.intercept(taskAlert);
+ await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'follow-up', taskId: 't1' });
+ expect(calls().map((c) => c[0])).toEqual(['onCalendarAlert', 'onBeforeCalendarAlert', 'onCalendarAlertAcknowledge']);
+ });
+
+ it("hands a plugin with calendar:read an event's reminder only", async () => {
+ mocks.background.hooks = reminderHooks;
+ await loadSandboxedPlugin(plugin({ permissions: ['calendar:read'] }));
+
+ await calendarHooks.onCalendarAlert.emit(taskAlert);
+ await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'follow-up', taskId: 't1' });
+ expect(calls()).toHaveLength(0);
+
+ await calendarHooks.onCalendarAlert.emit(eventAlert);
+ expect(calls().map((c) => c[0])).toEqual(['onCalendarAlert']);
+ });
+});
+
describe('permission tables', () => {
it('refuses a hook it does not know', () => {
expect(hookPermission('onSomethingNew')).toBeNull();
diff --git a/lib/__tests__/plugin-hooks.test.ts b/lib/__tests__/plugin-hooks.test.ts
index 68e7749ef..eb67afeb4 100644
--- a/lib/__tests__/plugin-hooks.test.ts
+++ b/lib/__tests__/plugin-hooks.test.ts
@@ -269,6 +269,25 @@ describe('onBeforeBlobUpload offload budget', () => {
});
});
+describe('onBeforeCalendarAlert budget', () => {
+ it('shows the reminder after 5s when a plugin hangs, not after the dialog budget', async () => {
+ vi.useFakeTimers();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ calendarHooks.onBeforeCalendarAlert.register('hung', () => new Promise(() => {}));
+ let allowed: boolean | undefined;
+ void calendarHooks.onBeforeCalendarAlert.intercept({ id: 'a1', taskId: 't1' }).then((a) => { allowed = a; });
+ await vi.advanceTimersByTimeAsync(4_900);
+ expect(allowed).toBeUndefined();
+ await vi.advanceTimersByTimeAsync(200);
+ expect(allowed).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ }
+ });
+});
+
describe('isExternalAttachmentResult', () => {
const valid = { externalAttachment: true, html: 'y', text: 'https://x/y' };
diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts
index 1ac16d526..2962cb5a2 100644
--- a/lib/__tests__/plugin-permission-coverage.test.ts
+++ b/lib/__tests__/plugin-permission-coverage.test.ts
@@ -36,7 +36,10 @@ function gatedPermissions(): Map {
where.set(perm, [...(where.get(perm) ?? []), by]);
};
for (const [method, perm] of Object.entries(PERM_PER_METHOD)) add(perm, `method ${method}`);
- for (const [hook, spec] of Object.entries(HOOK_CATALOG)) add(spec.permission, `hook ${hook}`);
+ for (const [hook, spec] of Object.entries(HOOK_CATALOG)) {
+ add(spec.permission, `hook ${hook}`);
+ if ('alsoPermission' in spec) add(spec.alsoPermission, `hook ${hook}`);
+ }
for (const [slot, spec] of Object.entries(SLOT_CATALOG)) {
for (const perm of spec.permissions) add(perm, `slot ${slot}`);
if (spec.props === '*') continue;
diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts
index 0b4a7febd..a0715d9d7 100644
--- a/lib/plugin-hook-catalog.ts
+++ b/lib/plugin-hook-catalog.ts
@@ -36,11 +36,26 @@ export interface HookSpec {
payload: string;
/** When the host fires it. */
when: string;
+ /**
+ * For a hook whose calls carry data behind one of two permissions (a
+ * reminder of an event or of a task): a plugin holding this one instead of
+ * `permission` may register too, and `callPermission` says which of the
+ * two each call needs, so a call reaches only plugins holding that one.
+ */
+ alsoPermission?: Permission;
+ callPermission?: (payload: unknown) => Permission;
}
const H = (group: HookGroup, kind: HookKind, permission: Permission, payload: string, when: string): HookSpec =>
({ group, kind, permission, payload, when });
+/** An event's reminder needs calendar:read, a task's tasks:read. */
+const REMINDER = {
+ alsoPermission: 'tasks:read',
+ callPermission: (payload: unknown): Permission =>
+ typeof payload === 'object' && payload !== null && 'taskId' in payload ? 'tasks:read' : 'calendar:read',
+} as const satisfies Pick;
+
export const HOOK_CATALOG = {
// ─── Mail: message list ────────────────────────────────────
onEmailsFetched: H('mail-list', 'transform', 'email:render-takeover', 'Email[] (list rows), { mailboxId }', 'after a page of the message list is loaded, before it is shown'),
@@ -150,8 +165,9 @@ export const HOOK_CATALOG = {
onCalendarChange: H('calendar', 'observer', 'calendar:read', '{ calendarId, change: "created" | "updated" | "removed" }', 'after a calendar was created, changed or removed'),
onCalendarVisibilityToggle: H('calendar', 'observer', 'calendar:read', '{ calendarId, visible }', 'when a calendar is shown or hidden'),
onICalSubscriptionChange: H('calendar', 'observer', 'calendar:read', '{ subscriptionId, change: "added" | "updated" | "removed" | "refreshed" }', 'when a calendar subscription changes'),
- onCalendarAlert: H('calendar', 'observer', 'calendar:read', 'CalendarAlertView', 'when an event reminder fires'),
- onCalendarAlertAcknowledge: H('calendar', 'observer', 'calendar:read', '{ alertId, eventId }', 'when a reminder is dismissed'),
+ onBeforeCalendarAlert: { ...H('calendar', 'intercept', 'calendar:read', 'CalendarAlertView', "before Bulwark shows a reminder of an event or a task, in every open tab; return false to show it yourself (no toast, sound or title alert from Bulwark; onCalendarAlert still fires); a task's needs tasks:read, an event's calendar:read"), ...REMINDER },
+ onCalendarAlert: { ...H('calendar', 'observer', 'calendar:read', 'CalendarAlertView ({ id, eventId, eventTitle, triggerTime } or { id, taskId, taskTitle, triggerTime })', "when a reminder of an event or a task fires, in every open tab, also one a plugin took over; a task's needs tasks:read, an event's calendar:read"), ...REMINDER },
+ onCalendarAlertAcknowledge: { ...H('calendar', 'observer', 'calendar:read', '{ alertId, eventId } or { alertId, taskId }', "when the user closes a reminder's toast, in that tab only; a task's needs tasks:read, an event's calendar:read"), ...REMINDER },
// ─── Contacts ──────────────────────────────────────────────
onContactOpen: H('contacts', 'observer', 'contacts:read', 'ContactView', 'when a contact is opened'),
diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts
index 989e65501..655ff4695 100644
--- a/lib/plugin-hooks.ts
+++ b/lib/plugin-hooks.ts
@@ -171,10 +171,13 @@ export class HookBus any> {
* cancels the action instead of letting it through.
* @param opts.onChange Called when handlers come or go, for UI that asked
* the bus before a plugin started (row badges).
+ * @param opts.interceptTimeoutMs For intercepts nobody waits on a user for
+ * (a reminder about to be shown): the per-handler budget of `intercept`
+ * instead of the long one for confirmation dialogs.
*/
constructor(
private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS,
- private readonly opts: { failClosed?: boolean; onChange?: () => void } = {},
+ private readonly opts: { failClosed?: boolean; onChange?: () => void; interceptTimeoutMs?: number } = {},
) {}
private setHandlers(next: HookEntry[]): void {
@@ -262,7 +265,8 @@ export class HookBus any> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
- const result = await withTimeout(pluginId, handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
+ const budget = this.opts.interceptTimeoutMs ?? Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS);
+ const result = await withTimeout(pluginId, handler(...args), budget);
if (result === false) return 'vetoed';
} catch (err) {
pluginErrorTracker.record(pluginId, err);
@@ -574,6 +578,12 @@ export const calendarHooks = {
onCalendarChange: new HookBus(),
onCalendarVisibilityToggle: new HookBus(),
onICalSubscriptionChange: new HookBus(),
+ // Intercept hook - fires before Bulwark shows a reminder (an event's or a
+ // task's display alert), in every open tab. Return false to show it
+ // yourself: no toast, no sound and no title alert for that reminder.
+ // Fails open within the default budget, so a hung plugin delays a
+ // reminder by seconds, never drops it.
+ onBeforeCalendarAlert: new HookBus(DEFAULT_TIMEOUT_MS, { interceptTimeoutMs: DEFAULT_TIMEOUT_MS }),
onCalendarAlert: new HookBus(),
onCalendarAlertAcknowledge: new HookBus(),
// Transform hook - fires when the event form is open and start/end change.
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
index ec61ff6e7..3760a86f5 100644
--- a/lib/plugin-sandbox/loader.ts
+++ b/lib/plugin-sandbox/loader.ts
@@ -24,7 +24,7 @@ import { register as registerActive, deregister as deregisterActive, all as allA
import { cancelPluginDialogs } from './host-api';
import { hasDialogFor, whenNoDialogFor } from './host-dialog';
import { registerShortcuts } from './shortcuts';
-import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
+import { mayOfferSlot, mayReceiveHookCall, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
import { clearPluginActions, setActionDispatcher } from './actions';
import { clearDayLabels } from './day-labels';
import { clearPluginListFilters } from './list-filters';
@@ -297,9 +297,15 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise {
// its own would ask twice (host-features.ts). Asked per call, so
// turning the core switch off gives the plugin its hook back.
// Parts of the arguments that need another permission are cut per
- // plugin (hook-payload.ts).
+ // plugin (hook-payload.ts). A hook whose calls carry data of two kinds
+ // (a reminder of an event or of a task) hands each call only to a
+ // plugin with its permission.
const pluginId = plugin.id;
- const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, hookArgsFor(plugin, hookName, args)));
+ const proxy = (...args: unknown[]) => (
+ isHookHandedOver(pluginId, hookName) || !mayReceiveHookCall(plugin, hookName, args)
+ ? undefined
+ : bg.invokeHook(hookName, hookArgsFor(plugin, hookName, args))
+ );
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
if (hookName === 'onEmailOpen') openHandler = proxy;
}
diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts
index 79a6c7620..46c704048 100644
--- a/lib/plugin-sandbox/permissions.ts
+++ b/lib/plugin-sandbox/permissions.ts
@@ -31,8 +31,20 @@ export function hookPermission(hookName: string): Permission | null {
}
export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): boolean {
- const perm = hookPermission(hookName);
- return perm !== null && pluginHasPermission(plugin, perm);
+ const spec = hookSpec(hookName);
+ if (!spec) return false;
+ return pluginHasPermission(plugin, spec.permission)
+ || (spec.alsoPermission !== undefined && pluginHasPermission(plugin, spec.alsoPermission));
+}
+
+/**
+ * Whether one call of a hook may reach `plugin`: always for a hook with one
+ * permission (registering checked it), else when the plugin holds the
+ * permission this call's payload needs (a task's reminder: tasks:read).
+ */
+export function mayReceiveHookCall(plugin: InstalledPlugin, hookName: string, args: unknown[]): boolean {
+ const needs = hookSpec(hookName)?.callPermission;
+ return needs === undefined || pluginHasPermission(plugin, needs(args[0]));
}
// ─── Slots ────────────────────────────────────────────────────
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index fa1a74b5d..199bf25d9 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -892,12 +892,20 @@ export interface QuotaView {
percentUsed: number;
}
-export interface CalendarAlertView {
- id: string;
- eventId: string;
- eventTitle: string;
- triggerTime: string;
-}
+/**
+ * A reminder that is due: an event's display alert or a task's. `id` is the
+ * alert's id within its event or task; `triggerTime` when it fired (UTC).
+ * A task's reaches only plugins with `tasks:read`, an event's only plugins
+ * with `calendar:read`.
+ */
+export type CalendarAlertView =
+ | { id: string; eventId: string; eventTitle: string; triggerTime: string }
+ | { id: string; taskId: string; taskTitle: string; triggerTime: string };
+
+/** A reminder's toast closed by the user (onCalendarAlertAcknowledge). */
+export type CalendarAlertAcknowledgeView =
+ | { alertId: string; eventId: string }
+ | { alertId: string; taskId: string };
export interface SearchFilters {
from?: string;
From b5558a66d88dfe59c0dbc109b14832c312f2c566 Mon Sep 17 00:00:00 2001
From: Jacob Zeitz
Date: Sun, 1 Nov 2026 20:07:03 +0100
Subject: [PATCH 03/21] feat(plugins): a side panel beside the mail for plugins
A frame slot `side-panel`: a plugin's panel right of the mail list and
reader, opened from a button in the panel rail (Mountain View's, or a slim
rail in the default layout while a plugin offers one). `ui.setSidePanel`
names its button; `ui.openPanel('side-panel')` opens it.
`ui.setSidePanel` takes `rail: false`: the rail draws no button for the
plugin's panel, which then opens only on the plugin's request
(openPanel / togglePanel('side-panel')), e.g. from a top bar action. A
rail left without buttons is not drawn, in the default layout and in
Mountain View's side panel alike.
---
components/layout/mv-side-panel.tsx | 45 +++-
components/mail/mail-app.tsx | 5 +
.../__tests__/plugin-side-panels.test.tsx | 169 +++++++++++++++
components/plugins/plugin-side-panels.tsx | 193 ++++++++++++++++++
lib/plugin-sandbox/host-api.ts | 4 +
lib/plugin-sandbox/loader.ts | 2 +
lib/plugin-sandbox/panel-requests.ts | 10 +-
lib/plugin-sandbox/runtime.tsx | 17 +-
lib/plugin-sandbox/side-panels.ts | 72 +++++++
lib/plugin-sandbox/slot-layout.ts | 2 +-
lib/plugin-slot-catalog.ts | 1 +
11 files changed, 506 insertions(+), 14 deletions(-)
create mode 100644 components/plugins/__tests__/plugin-side-panels.test.tsx
create mode 100644 components/plugins/plugin-side-panels.tsx
create mode 100644 lib/plugin-sandbox/side-panels.ts
diff --git a/components/layout/mv-side-panel.tsx b/components/layout/mv-side-panel.tsx
index 3a2e54241..40d8ba4dd 100644
--- a/components/layout/mv-side-panel.tsx
+++ b/components/layout/mv-side-panel.tsx
@@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform";
import { Avatar } from "@/components/ui/avatar";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCvdColor } from "@/hooks/use-cvd-color";
+import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels";
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
@@ -46,6 +47,18 @@ export function MvSidePanel() {
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const [open, setOpen] = useState(null);
+ // Plugins' panels (the side-panel slot), their buttons below the apps'.
+ // A plugin's panel opening (a click, or the plugin's openPanel) shuts the app's, and the other way round.
+ const plugins = usePluginSidePanel(() => setOpen(null));
+ const pluginOpen = plugins.entries.find((e) => e.pluginId === plugins.open) ?? null;
+ const openApp = (id: MvSidePanelId | null) => {
+ if (id) plugins.choose(null);
+ setOpen(id);
+ };
+ const openPlugin = (pluginId: string | null) => {
+ if (pluginId) setOpen(null);
+ plugins.choose(pluginId);
+ };
const showCalendar = supportsCalendar && calendarEnabled;
const showContacts = (client?.supportsContacts() ?? false) && contactsEnabled;
@@ -57,9 +70,11 @@ export function MvSidePanel() {
...(showContacts ? [{ id: "contacts" as const, label: t("contacts"), icon: Users, color: "text-[#1a73e8]" }] : []),
];
- if (buttons.length === 0) return null;
+ if (buttons.length === 0 && plugins.entries.length === 0) return null;
+ // A plugin's panel without a rail button (setSidePanel's rail: false) opens on its request only.
+ const pluginButtons = plugins.entries.filter((e) => e.inRail);
// A panel whose app went away (policy change, account switch) stays shut.
- const active = open && buttons.some((b) => b.id === open) ? open : null;
+ const active = open && !pluginOpen && buttons.some((b) => b.id === open) ? open : null;
return (
<>
@@ -75,7 +90,17 @@ export function MvSidePanel() {
{active === "contacts" && setOpen(null)} />}
)}
- }
>
);
}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 33ae06070..598fb0f75 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -107,6 +107,7 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
import { useAppCommands } from "@/lib/app-commands";
import { MvSearchForm } from "@/components/search/mv-search-form";
import { MvSidePanel } from "@/components/layout/mv-side-panel";
+import { PluginSidePanel } from "@/components/plugins/plugin-side-panels";
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
@@ -5762,6 +5763,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Mountain View: Gmail's side panel - calendar, tasks and contacts
next to the mail, with their icons down the right edge. */}
{mvShell && !inlineApp && !fullscreenReading && }
+ {/* The default layout: plugins' panels beside the mail (the
+ side-panel slot), with their buttons in a rail at the right edge.
+ Desktop only; Mountain View has them in its side panel. */}
+ {!mvShell && !isMobile && !isTablet && !isEmbedded && !inlineApp && !fullscreenReading && }
{/* Keyboard Shortcuts Modal */}
diff --git a/components/plugins/__tests__/plugin-side-panels.test.tsx b/components/plugins/__tests__/plugin-side-panels.test.tsx
new file mode 100644
index 000000000..d250299c0
--- /dev/null
+++ b/components/plugins/__tests__/plugin-side-panels.test.tsx
@@ -0,0 +1,169 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import type { InstalledPlugin } from '@/lib/plugin-types';
+
+const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
+vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
+ createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
+}));
+vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
+
+const { PluginSidePanel } = await import('../plugin-side-panels');
+const { MvSidePanel } = await import('@/components/layout/mv-side-panel');
+const registry = await import('@/lib/plugin-sandbox/registry');
+const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
+const { clearSidePanel } = await import('@/lib/plugin-sandbox/side-panels');
+const { useCalendarStore } = await import('@/stores/calendar-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+function offer(id: string, name: string, opts: { slot?: string; fill?: boolean } = {}) {
+ const plugin: InstalledPlugin = {
+ id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
+ permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+ registry.register({
+ plugin,
+ code: '',
+ tier: 'untrusted',
+ background: { evaluateShouldShow: vi.fn(async () => true) } as never,
+ slotOffers: [{ name: (opts.slot ?? 'side-panel') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }],
+ hookDisposables: [],
+ });
+ return plugin;
+}
+
+beforeEach(() => {
+ createSlotInstance.mockClear();
+ localStorage.clear();
+});
+
+afterEach(() => {
+ for (const p of registry.all()) {
+ clearSidePanel(p.plugin.id);
+ registry.deregister(p.plugin.id);
+ }
+});
+
+describe('PluginSidePanel (default layout)', () => {
+ it('draws nothing when no plugin offers a side panel', () => {
+ offer('w', 'Widget', { slot: 'sidebar-widget' });
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('gives each plugin a button in the rail that opens and closes its panel, filled by the frame', async () => {
+ offer('fu', 'Follow-ups', { fill: true });
+ const { container } = render();
+ const button = screen.getByRole('button', { name: 'Follow-ups' });
+ expect(button).toHaveAttribute('aria-pressed', 'false');
+ expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
+
+ fireEvent.click(button);
+ expect(button).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('complementary', { name: 'Follow-ups' })).toBeInTheDocument();
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record; fill?: boolean };
+ expect(opts.slot).toBe('side-panel');
+ expect(opts.extraProps).toEqual({ focusRequest: 0 });
+ expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
+ });
+
+ it('remembers the open panel in this browser', () => {
+ offer('fu', 'Follow-ups');
+ const first = render();
+ fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
+ first.unmount();
+
+ const { container } = render();
+ expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
+ });
+
+ it('names the button and panel as the plugin set them, and by its name again after null', async () => {
+ const plugin = offer('fu', 'Follow-ups');
+ render();
+ await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag' }]); });
+ expect(screen.getByRole('button', { name: 'Upcoming for me' })).toBeInTheDocument();
+
+ await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'x'.repeat(41) }])).rejects.toThrow(/at most 40/);
+ await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ icon: 'no icon' }])).rejects.toThrow(/icon/);
+ await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [null]); });
+ expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
+
+ const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] };
+ await expect(dispatchApiCall(noPerm, 'ui.setSidePanel', [{ title: 'Mine' }])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/);
+ });
+
+ it('leaves the button out with rail: false, the panel opened on the plugin\'s request only', async () => {
+ const plugin = offer('fu', 'Follow-ups');
+ const { container } = render();
+ await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag', rail: false }]); });
+ expect(screen.queryByRole('button', { name: 'Upcoming for me' })).toBeNull();
+ expect(container.querySelector('[data-plugin-side-rail]')).toBeNull();
+
+ await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
+ expect(screen.getByRole('complementary', { name: 'Upcoming for me' })).toBeInTheDocument();
+
+ await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: 'no' }])).rejects.toThrow(/rail/);
+ });
+
+ it('opens on the plugin\'s request with a new focusRequest, and answers null without a panel on screen', async () => {
+ const plugin = offer('fu', 'Follow-ups');
+ const { container, unmount } = render();
+ let answer: unknown;
+ await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
+ expect(answer).toBe(true);
+ expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ focusRequest: 1 });
+
+ await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
+ expect(answer).toBe(false);
+ expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
+
+ unmount();
+ await expect(dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open'])).resolves.toBeNull();
+ });
+});
+
+describe('MvSidePanel with plugin panels', () => {
+ beforeEach(() => {
+ useCalendarStore.setState({ supportsCalendar: true, calendars: [], events: [] } as never);
+ useSettingsStore.setState({ enableCalendarTasks: false } as never);
+ });
+
+ it('adds the plugins\' buttons to its rail, one panel open at a time', () => {
+ offer('fu', 'Follow-ups');
+ const { container } = render();
+ fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
+ expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
+ expect(container.querySelector('[data-mv-side-panel]')).toBeNull();
+ expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
+ expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
+ expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
+ });
+
+ it('draws no rail for a plugin panel without a button alone, and opens it on request', async () => {
+ useCalendarStore.setState({ supportsCalendar: false } as never);
+ const plugin = offer('fu', 'Follow-ups');
+ const { container } = render();
+ await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: false }]); });
+ expect(container.querySelector('[data-mv-side-rail]')).toBeNull();
+ await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
+ expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
+ });
+
+ it('shows the rail for a plugin panel alone', () => {
+ useCalendarStore.setState({ supportsCalendar: false } as never);
+ offer('fu', 'Follow-ups');
+ render();
+ expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
+ });
+});
diff --git a/components/plugins/plugin-side-panels.tsx b/components/plugins/plugin-side-panels.tsx
new file mode 100644
index 000000000..b81d389e2
--- /dev/null
+++ b/components/plugins/plugin-side-panels.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
+import { useTranslations } from 'next-intl';
+import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
+import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
+import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
+import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
+import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
+import { cn } from '@/lib/utils';
+import { PluginIframeSlot } from './plugin-iframe-slot';
+
+// The `side-panel` slot: one panel per plugin beside the mail, opened from
+// its button in the rail at the right edge. Mountain View puts the buttons
+// in its own side panel's rail (mv-side-panel.tsx); the default layout
+// shows PluginSidePanel. At most one panel is open; which one is
+// remembered in this browser. A plugin that set `rail: false` has no
+// button: its panel opens only on its request; a rail without buttons is
+// not drawn.
+
+const STORAGE_KEY = 'plugin-side-panel-open';
+
+export interface SidePanelEntry {
+ pluginId: string;
+ title: string;
+ Icon: AppIcon;
+ fill: boolean;
+ /** Whether the rail shows its button (api.ui.setSidePanel's `rail`). */
+ inRail: boolean;
+}
+
+function readStored(): string | null {
+ try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
+}
+
+function writeStored(pluginId: string | null): void {
+ try {
+ if (pluginId) localStorage.setItem(STORAGE_KEY, pluginId);
+ else localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Storage may be blocked; the panel is just shut next time.
+ }
+}
+
+/** The plugins offering a side panel, each with the title and icon it set (api.ui.setSidePanel). */
+export function usePluginSidePanelEntries(): SidePanelEntry[] {
+ const offers = usePluginSlotOffers('side-panel');
+ const looks = useSyncExternalStore(subscribeSidePanels, sidePanelLooks, sidePanelLooks);
+ return useMemo(() => {
+ return offers.map((offer) => {
+ const look = looks.get(offer.pluginId);
+ return {
+ pluginId: offer.pluginId,
+ title: look?.title || getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId,
+ Icon: iconForName(look?.icon) ?? Puzzle,
+ fill: !!offer.fill,
+ inRail: look?.rail !== false,
+ };
+ });
+ }, [offers, looks]);
+}
+
+/**
+ * Which plugin's side panel is open (null: none), remembered in this
+ * browser, and the plugins' api.ui.openPanel('side-panel') requests;
+ * `onOpen` runs when a plugin's request opens its panel.
+ */
+export function usePluginSidePanel(onOpen?: () => void) {
+ const entries = usePluginSidePanelEntries();
+ const [chosen, setChosen] = useState(readStored);
+ const [focusRequest, setFocusRequest] = useState(0);
+ // A plugin that went away (disabled, removed) leaves its panel shut.
+ const open = chosen && entries.some((e) => e.pluginId === chosen) ? chosen : null;
+ const choose = useCallback((pluginId: string | null) => {
+ setChosen(pluginId);
+ writeStored(pluginId);
+ }, []);
+ const state = useRef({ entries, open, onOpen });
+ state.current = { entries, open, onOpen };
+ useEffect(() => onPanelRequest((request) => {
+ if (request.slot !== 'side-panel') return null;
+ if (!state.current.entries.some((e) => e.pluginId === request.pluginId)) return null;
+ const isOpen = state.current.open === request.pluginId;
+ const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
+ if (wantOpen) {
+ choose(request.pluginId);
+ setFocusRequest((n) => n + 1);
+ state.current.onOpen?.();
+ } else if (isOpen) {
+ choose(null);
+ }
+ return wantOpen;
+ }), [choose]);
+ return { entries, open, choose, focusRequest };
+}
+
+/** A plugin's panel: the host's header (title, close) over the plugin's frame, which fills the rest. */
+export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
+ entry: SidePanelEntry;
+ focusRequest: number;
+ onClose: () => void;
+}) {
+ const tCommon = useTranslations('common');
+ const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
+ return (
+ <>
+
+
{entry.title}
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * The default layout's side panel: a slim rail at the right edge with a
+ * button per plugin, and the open plugin's panel left of it. Nothing
+ * without a plugin offering one.
+ */
+export function PluginSidePanel() {
+ const t = useTranslations('sidebar');
+ const { entries, open, choose, focusRequest } = usePluginSidePanel();
+ if (entries.length === 0) return null;
+ const active = entries.find((e) => e.pluginId === open) ?? null;
+ const inRail = entries.filter((e) => e.inRail);
+ return (
+ <>
+ {active && (
+
+ )}
+ {inRail.length > 0 &&
+ {inRail.map((entry) => (
+ choose(open === entry.pluginId ? null : entry.pluginId)}
+ className="h-9 w-9 rounded-md hover:bg-muted"
+ pressedClassName="bg-muted text-foreground"
+ />
+ ))}
+ }
+ >
+ );
+}
+
+/** A plugin's button in a side panel's rail. */
+export function SidePanelButton({ entry, pressed, onClick, className, pressedClassName }: {
+ entry: SidePanelEntry;
+ pressed: boolean;
+ onClick: () => void;
+ className?: string;
+ pressedClassName?: string;
+}) {
+ const { Icon } = entry;
+ return (
+
+ );
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 76e9a50e7..8a645afaa 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -67,6 +67,7 @@ import { doUiPrint } from './host-api-print';
import { doNotificationsShow } from './host-api-notify';
import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account';
import { setSectionTitle } from './section-titles';
+import { setSidePanel } from './side-panels';
import { requestPanel } from './panel-requests';
import { requestPageNavigation } from './page-navigation';
import { requestOpenEmail } from './open-email';
@@ -257,6 +258,8 @@ export const PERM_PER_METHOD: Readonly> = {
// Opening a message shows its content, as reading it does.
'ui.openEmail': 'email:read',
'ui.setSectionTitle': 'ui:sidebar-widget',
+ // The button and header of the plugin's side-panel slot beside the mail.
+ 'ui.setSidePanel': 'ui:sidebar-widget',
// Open, close or toggle the panel the plugin's slot sits in; the panel's
// own permission is checked per call (see the slot catalogue).
'ui.setPanel': null,
@@ -2037,6 +2040,7 @@ export async function dispatchApiCall(
case 'ui.openPage': return requestPageNavigation(plugin.id, args[0], args[1]);
case 'ui.openEmail': return requestOpenEmail(plugin, args[0]);
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
+ case 'ui.setSidePanel': setSidePanel(plugin.id, args[0]); return undefined;
case 'ui.setPanel': {
const slot = args[0];
if (typeof slot !== 'string' || !mayOfferSlot(plugin, slot)) throw new Error(`Plugin "${plugin.id}" may not open the panel "${String(slot)}"`);
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
index ec61ff6e7..3df6b7755 100644
--- a/lib/plugin-sandbox/loader.ts
+++ b/lib/plugin-sandbox/loader.ts
@@ -30,6 +30,7 @@ import { clearDayLabels } from './day-labels';
import { clearPluginListFilters } from './list-filters';
import { clearPluginPages } from './pages';
import { clearSectionTitle } from './section-titles';
+import { clearSidePanel } from './side-panels';
import { cancelPluginProxyRequests } from './proxy-client';
import { cancelConnectFor } from './connect-dialog';
import { isHookHandedOver } from './host-features';
@@ -390,6 +391,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
clearDayLabels(pluginId);
clearPluginPages(pluginId);
clearSectionTitle(pluginId);
+ clearSidePanel(pluginId);
cancelPluginProxyRequests(pluginId);
cancelConnectFor(pluginId);
try { entry.background.destroy(); } catch { /* ignore */ }
diff --git a/lib/plugin-sandbox/panel-requests.ts b/lib/plugin-sandbox/panel-requests.ts
index fc11423e1..e7f26887b 100644
--- a/lib/plugin-sandbox/panel-requests.ts
+++ b/lib/plugin-sandbox/panel-requests.ts
@@ -1,15 +1,15 @@
// Plugins opening and closing the panel their slot sits in
-// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels
-// and the reader's detail panel. A toolbar button, a shortcut or a
-// right-click entry can then bring the plugin's panel up instead of a
-// dialog. The panels subscribe here while they are on screen; a request
+// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels,
+// the reader's detail panel and the panel beside the mail (side-panel). A
+// toolbar button, a shortcut or a right-click entry can then bring the
+// plugin's panel up instead of a dialog. The panels subscribe here while they are on screen; a request
// nobody answers (no composer open, no message open, a phone) resolves to
// null, so the plugin can fall back to a dialog.
//
// When a panel opens on request, its slot gets a new `focusRequest` number
// in its props, so the plugin can put the focus where it belongs.
-export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'] as const;
+export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'side-panel'] as const;
export type PanelSlot = typeof PANEL_SLOTS[number];
export type PanelMode = 'open' | 'close' | 'toggle';
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index daf0ee0db..9e393f74a 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -758,12 +758,23 @@ function buildPluginApi(manifest: PluginManifest) {
* back to the plugin's name. At most 40 characters.
*/
setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise,
+ /**
+ * The button and header of this plugin's `side-panel` slot, the panel
+ * beside the mail: a title (at most 40 characters, e.g. a translated
+ * "Upcoming for me") and a host icon by name (as for setPages). null
+ * goes back to the plugin's name and a puzzle piece. `rail: false`
+ * draws no button in the rail: the plugin opens the panel itself
+ * with openPanel / togglePanel('side-panel'), e.g. from a top bar
+ * action.
+ */
+ setSidePanel: (look: { title?: string; icon?: string; rail?: boolean } | null) => callApi('ui.setSidePanel', [look]) as Promise,
/**
* Open, close or toggle the panel this plugin's slot sits in:
* 'composer-sidebar', 'composer-sidebar-right' (pass the composerId
- * from the click context or the slot's props) or 'email-detail-sidebar'.
- * Resolve to whether the panel is open afterwards, or null when no such
- * panel is on screen (no composer or message open, or a phone), so the
+ * from the click context or the slot's props), 'email-detail-sidebar'
+ * or 'side-panel' (this plugin's panel beside the mail). Resolve to
+ * whether the panel is open afterwards, or null when no such panel is
+ * on screen (no composer or message open, or a phone), so the
* plugin can fall back to a dialog. A panel opened this way gets a new
* `focusRequest` prop, so the slot can focus its input.
*/
diff --git a/lib/plugin-sandbox/side-panels.ts b/lib/plugin-sandbox/side-panels.ts
new file mode 100644
index 000000000..90a937a88
--- /dev/null
+++ b/lib/plugin-sandbox/side-panels.ts
@@ -0,0 +1,72 @@
+// How plugins' side panels are named in the rail beside the mail
+// (api.ui.setSidePanel): a title and a host icon. The host draws the button
+// and the panel's header itself; without a title it shows the plugin's name,
+// without an icon a puzzle piece. `rail: false` leaves the button out: the
+// plugin opens its panel itself (api.ui.openPanel / togglePanel), e.g. from
+// a top bar action.
+
+export interface SidePanelLook {
+ title?: string;
+ /** Name of a host icon (see components/icons), as for api.ui.setPages. */
+ icon?: string;
+ /** false: no button in the rail; the panel opens only on the plugin's request. */
+ rail?: boolean;
+}
+
+export const MAX_SIDE_PANEL_TITLE = 40;
+const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
+
+let looks: ReadonlyMap = new Map();
+const listeners = new Set<() => void>();
+
+function changed(): void {
+ for (const l of listeners) {
+ try { l(); } catch { /* ignore */ }
+ }
+}
+
+/** Check and store a panel's title and icon (null clears both); throws with a message the plugin sees. */
+export function setSidePanel(pluginId: string, value: unknown): void {
+ if (value === null || value === undefined) {
+ clearSidePanel(pluginId);
+ return;
+ }
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error('ui.setSidePanel: expects { title, icon, rail } or null');
+ const raw = value as Record;
+ const look: SidePanelLook = {};
+ if (raw.title !== undefined && raw.title !== null) {
+ if (typeof raw.title !== 'string') throw new Error('ui.setSidePanel: title must be a string');
+ // eslint-disable-next-line no-control-regex -- one line of text
+ const title = raw.title.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
+ if (title.length > MAX_SIDE_PANEL_TITLE) throw new Error(`ui.setSidePanel: title has at most ${MAX_SIDE_PANEL_TITLE} characters`);
+ if (title) look.title = title;
+ }
+ if (raw.icon !== undefined && raw.icon !== null) {
+ if (typeof raw.icon !== 'string' || !ICON_RE.test(raw.icon)) throw new Error('ui.setSidePanel: icon is invalid');
+ look.icon = raw.icon;
+ }
+ if (raw.rail !== undefined && raw.rail !== null) {
+ if (typeof raw.rail !== 'boolean') throw new Error('ui.setSidePanel: rail must be a boolean');
+ if (!raw.rail) look.rail = false;
+ }
+ looks = new Map(looks).set(pluginId, look);
+ changed();
+}
+
+/** Every plugin's title and icon; a new map after each change, the same one between. */
+export function sidePanelLooks(): ReadonlyMap {
+ return looks;
+}
+
+export function clearSidePanel(pluginId: string): void {
+ if (!looks.has(pluginId)) return;
+ const next = new Map(looks);
+ next.delete(pluginId);
+ looks = next;
+ changed();
+}
+
+export function subscribeSidePanels(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
diff --git a/lib/plugin-sandbox/slot-layout.ts b/lib/plugin-sandbox/slot-layout.ts
index d3dedb2e6..495e708ad 100644
--- a/lib/plugin-sandbox/slot-layout.ts
+++ b/lib/plugin-sandbox/slot-layout.ts
@@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null {
* can pin controls at the bottom, like a chat input. Elsewhere a frame grows
* with its content.
*/
-export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page'];
+export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'side-panel', 'app-page'];
export function isFillableSlot(slot: string): boolean {
return FILLABLE_SLOTS.includes(slot);
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 043667986..5e277d665 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -51,6 +51,7 @@ export const SLOT_CATALOG = {
'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'),
'sidebar-widget': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'a widget at the bottom of the mail sidebar'),
'sidebar-section': S('navigation', 'frame', ['ui:sidebar-widget'], { mailboxId: null }, 'a collapsible section in the mail sidebar, below the folders'),
+ 'side-panel': S('navigation', 'frame', ['ui:sidebar-widget'], { focusRequest: null }, 'a panel beside the mail (list and reader), opened from its button in the rail at the right edge (Mountain View\'s side panel; in the default layout a rail of its own, desktop only); the button\'s title and icon from api.ui.setSidePanel, else the plugin\'s name, no button with its `rail: false` (then opened by api.ui.openPanel / togglePanel only); the open panel is remembered in this browser; `slots[\'side-panel\'].fill = true` makes the frame as tall as the panel; `focusRequest` counts up when the plugin opened it with api.ui.openPanel(\'side-panel\')'),
'sidebar-section-actions': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'compact controls beside a collapsible plugin sidebar-section heading', true),
'top-bar-actions': S('navigation', 'action', ['ui:toolbar'], {}, 'buttons in the top bar, next to search', true),
'app-page': S('navigation', 'frame', ['ui:app-page'], { pageId: null, path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.setPages); api.ui.openPage moves the tab there'),
From b9fbe811ba1a4a90dbd77bf03f1b409c0f148246 Mon Sep 17 00:00:00 2001
From: Jacob Zeitz
Date: Sat, 31 Oct 2026 11:44:13 +0100
Subject: [PATCH 04/21] feat(plugins): a plugin dialog as a popover at the
clicked control
`ui.openDialog({ anchor: 'trigger' })`: opened within 2 s of a click or
Enter/Space on a host control (a plugin action, a context-menu entry, the
star), the dialog stands as a popover beside that control, still naming
the plugin; without such a trigger it is the centred dialog as before.
---
.../__tests__/plugin-dialog-popover.test.tsx | 62 ++++++++
components/plugins/plugin-dialog-host.tsx | 145 ++++++++++++++----
lib/__tests__/plugin-dialog-anchor.test.ts | 136 ++++++++++++++++
lib/plugin-sandbox/dialog-anchor.ts | 125 +++++++++++++++
lib/plugin-sandbox/host-api.ts | 6 +-
lib/plugin-sandbox/host-dialog.ts | 8 +
lib/plugin-sandbox/runtime.tsx | 14 +-
lib/plugin-slot-catalog.ts | 2 +-
8 files changed, 466 insertions(+), 32 deletions(-)
create mode 100644 components/plugins/__tests__/plugin-dialog-popover.test.tsx
create mode 100644 lib/__tests__/plugin-dialog-anchor.test.ts
create mode 100644 lib/plugin-sandbox/dialog-anchor.ts
diff --git a/components/plugins/__tests__/plugin-dialog-popover.test.tsx b/components/plugins/__tests__/plugin-dialog-popover.test.tsx
new file mode 100644
index 000000000..699b86019
--- /dev/null
+++ b/components/plugins/__tests__/plugin-dialog-popover.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string, values?: Record) => (values?.name ? `${key}:${values.name}` : key) }));
+vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => }));
+
+import { PluginDialogHost } from '../plugin-dialog-host';
+import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
+import { POPOVER_GAP } from '@/lib/plugin-sandbox/dialog-anchor';
+
+// A custom dialog with an anchor: a popover beside the control the user
+// clicked, naming the plugin in one line, closed by Escape or a click outside.
+
+beforeEach(() => {
+ while (head()) resolveHead(null);
+ resetDialogLimits();
+});
+
+const anchor = { left: 100, top: 50, right: 124, bottom: 74 };
+
+describe('a plugin dialog beside its trigger', () => {
+ it('stands below the trigger, names the plugin, and has no centred backdrop', () => {
+ render();
+ act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', width: 320, anchor }); });
+
+ const dialog = screen.getByRole('dialog', { name: 'Follow up' });
+ expect(dialog).toHaveAttribute('data-plugin-dialog-popover');
+ expect(dialog.className).not.toMatch(/bg-black/);
+ expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
+ expect(dialog.querySelector('[data-plugin-dialog-source]')).toHaveTextContent('from_plugin:follow-ups');
+ expect(screen.getByRole('button', { name: 'plugin_menu:follow-ups' })).toBeInTheDocument();
+ const panel = dialog.querySelector('[data-plugin-dialog="follow-ups"]')!;
+ expect(panel.style.left).toBe('100px');
+ expect(panel.style.top).toBe(`${74 + POPOVER_GAP}px`);
+ expect(panel.style.width).toContain('320px');
+ });
+
+ it('closes with null on a click outside', async () => {
+ render();
+ let result: Promise;
+ act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
+ await act(async () => { fireEvent.mouseDown(screen.getByRole('dialog')); });
+ await expect(result!).resolves.toBeNull();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('closes with null on Escape', async () => {
+ render();
+ let result: Promise;
+ act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
+ await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); });
+ await expect(result!).resolves.toBeNull();
+ });
+
+ it('is the centred dialog without an anchor', () => {
+ render();
+ act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog' }); });
+ const dialog = screen.getByRole('dialog', { name: 'Follow up' });
+ expect(dialog).not.toHaveAttribute('data-plugin-dialog-popover');
+ expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
+ });
+});
diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx
index 7032cd9ad..13f4cee80 100644
--- a/components/plugins/plugin-dialog-host.tsx
+++ b/components/plugins/plugin-dialog-host.tsx
@@ -9,11 +9,14 @@
// They look like the app's own dialogs: a custom dialog (api.ui.openDialog)
// is the filter editor's layout, with the plugin's buttons (kit
// DialogButtons) drawn by the host in its footer; confirm, alert and prompt
-// are the app's ConfirmDialog and PromptDialog.
+// are the app's ConfirmDialog and PromptDialog. A custom dialog opened with
+// `anchor: 'trigger'` is a popover beside the control the user clicked
+// (dialog-anchor.ts), with a one-line header naming the plugin.
-import React, { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
+import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
import { cancelForPlugin, head, overlay, resolveDialog, subscribe, type DialogRequest } from '@/lib/plugin-sandbox/host-dialog';
+import { placePopover, POPOVER_MARGIN, trackTriggers, type AnchorRect } from '@/lib/plugin-sandbox/dialog-anchor';
import type { DialogButtonSpec } from '@/lib/plugin-sandbox/ui-kit/dialog-buttons';
import { usePluginStore } from '@/stores/plugin-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -43,6 +46,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
const top = useSyncExternalStore(subscribe, overlay, () => null);
const t = useTranslations('plugins.dialog');
+ // What the user clicked last, for a dialog that opens beside it.
+ useEffect(() => trackTriggers(), []);
+
// Say so when a plugin was turned off for failing; otherwise it just
// stops working with no hint why.
useEffect(() => {
@@ -183,6 +189,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
},
});
+ const anchor = current?.kind === 'custom' ? current.anchor : undefined;
+ const place = usePopoverPlace(anchor, panelRef);
+
if (!current) return null;
const name = pluginName ?? current.pluginId;
@@ -204,6 +213,76 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
+ );
+ const frame = (
+
+ );
+
+ // Beside the control the user clicked: no backdrop, no big header. The
+ // plugin is still named in the host's own line, with the way to turn it
+ // off; a click outside or Escape closes it as the dialog's X does.
+ if (anchor) {
+ return (
+
- )}
+ {footer}
>
) : (
<>
@@ -328,6 +386,37 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
);
}
+/**
+ * Where a popover dialog's panel stands: measured before it paints and again
+ * when its content (the plugin's frame) or the window changes size.
+ */
+function usePopoverPlace(anchor: AnchorRect | undefined, panelRef: React.RefObject): { left: number; top: number } | null {
+ const [place, setPlace] = useState<{ left: number; top: number } | null>(null);
+ useLayoutEffect(() => {
+ const panel = panelRef.current;
+ if (!anchor || !panel) return;
+ const update = () => {
+ const r = panel.getBoundingClientRect();
+ const next = placePopover(
+ anchor,
+ { width: r.width, height: r.height },
+ { width: window.innerWidth, height: window.innerHeight },
+ document.documentElement.dir === 'rtl',
+ );
+ setPlace((p) => (p && p.left === next.left && p.top === next.top ? p : next));
+ };
+ update();
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
+ observer?.observe(panel);
+ window.addEventListener('resize', update);
+ return () => {
+ observer?.disconnect();
+ window.removeEventListener('resize', update);
+ };
+ }, [anchor, panelRef]);
+ return place;
+}
+
/** A footer button's icon by name; unknown names show none. */
function FooterIcon({ name }: { name?: string }) {
const Glyph = name ? iconForName(name) : null;
@@ -335,7 +424,7 @@ function FooterIcon({ name }: { name?: string }) {
}
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
-function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
+function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOff: () => void; compact?: boolean }) {
const t = useTranslations('plugins.dialog');
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -356,9 +445,9 @@ function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () =>
aria-expanded={open}
aria-label={t('plugin_menu', { name })}
title={t('plugin_menu', { name })}
- className={cn('p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
+ className={cn(compact ? 'p-1' : 'p-1.5', 'rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
>
-
+
{open && (
diff --git a/lib/__tests__/plugin-dialog-anchor.test.ts b/lib/__tests__/plugin-dialog-anchor.test.ts
new file mode 100644
index 000000000..77a9c71d3
--- /dev/null
+++ b/lib/__tests__/plugin-dialog-anchor.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+import { head, resetDialogLimits, resolveHead } from '../plugin-sandbox/host-dialog';
+import {
+ forgetTrigger, noteTrigger, placePopover, trackTriggers, triggerAnchor, POPOVER_GAP, POPOVER_MARGIN, TRIGGER_WINDOW_MS,
+} from '../plugin-sandbox/dialog-anchor';
+
+// api.ui.openDialog({ anchor: 'trigger' }): the dialog beside the control the
+// user activated last, when that was a moment ago.
+
+function plugin(): InstalledPlugin {
+ return {
+ id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
+ permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
+ };
+}
+
+/** A control on the page standing at this rect. */
+function control(html: string, rect: { left: number; top: number; width: number; height: number }): HTMLElement {
+ const host = document.createElement('div');
+ host.innerHTML = html;
+ document.body.appendChild(host);
+ const el = host.querySelector('[data-at]') ?? host.firstElementChild as HTMLElement;
+ el.getBoundingClientRect = () => ({
+ left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height,
+ width: rect.width, height: rect.height, x: rect.left, y: rect.top, toJSON: () => ({}),
+ });
+ return el;
+}
+
+let stop: () => void = () => {};
+beforeEach(() => {
+ forgetTrigger();
+ resetDialogLimits();
+ while (head()) resolveHead(null);
+ stop = trackTriggers();
+});
+afterEach(() => {
+ stop();
+ document.body.innerHTML = '';
+});
+
+describe('the trigger a dialog opens beside', () => {
+ it('is the button under the pointer, measured again when the dialog opens', () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.querySelector('span')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 110, clientY: 60 }));
+ expect(triggerAnchor()).toEqual({ left: 100, top: 50, right: 124, bottom: 74 });
+ });
+
+ it('is the point a menu entry was chosen at, the menu being gone', () => {
+ const entry = control('
Follow up
', { left: 300, top: 200, width: 180, height: 28 });
+ entry.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 320, clientY: 210 }));
+ entry.parentElement!.remove();
+ expect(triggerAnchor()).toEqual({ left: 320, top: 210, right: 320, bottom: 210 });
+ });
+
+ it('is the focused control on Enter, and nothing after another key', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ button.focus();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ expect(triggerAnchor()).toEqual({ left: 10, top: 20, right: 30, bottom: 40 });
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
+ expect(triggerAnchor()).not.toBeNull();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }));
+ expect(triggerAnchor()).toBeNull();
+ });
+
+ it('is forgotten after the window', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ noteTrigger(button, undefined, 1000);
+ expect(triggerAnchor(1000 + TRIGGER_WINDOW_MS)).not.toBeNull();
+ expect(triggerAnchor(1001 + TRIGGER_WINDOW_MS)).toBeNull();
+ });
+
+ it('keeps its rect when the control has gone', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ noteTrigger(button);
+ button.remove();
+ expect(triggerAnchor()).toEqual({ left: 10, top: 20, right: 30, bottom: 40 });
+ });
+});
+
+describe('placePopover', () => {
+ const viewport = { width: 1000, height: 800 };
+ const size = { width: 320, height: 200 };
+
+ it('stands below the anchor, at its start', () => {
+ expect(placePopover({ left: 100, top: 50, right: 124, bottom: 74 }, size, viewport))
+ .toEqual({ left: 100, top: 74 + POPOVER_GAP });
+ });
+
+ it('lines up with the end in RTL', () => {
+ expect(placePopover({ left: 600, top: 50, right: 624, bottom: 74 }, size, viewport, true).left).toBe(624 - 320);
+ });
+
+ it('goes above when it does not fit below', () => {
+ expect(placePopover({ left: 100, top: 700, right: 124, bottom: 724 }, size, viewport).top).toBe(700 - POPOVER_GAP - 200);
+ });
+
+ it('stays inside the window', () => {
+ expect(placePopover({ left: 950, top: 10, right: 980, bottom: 30 }, size, viewport).left).toBe(1000 - POPOVER_MARGIN - 320);
+ expect(placePopover({ left: 100, top: 300, right: 100, bottom: 300 }, { width: 320, height: 700 }, viewport).top)
+ .toBe(800 - POPOVER_MARGIN - 700);
+ });
+});
+
+describe("ui.openDialog's anchor", () => {
+ it("is the trigger's place when the plugin asks for it", async () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 110, clientY: 60 }));
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [{ slot: 'plugin-dialog', anchor: 'trigger', width: 320 }]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toEqual({ left: 100, top: 50, right: 124, bottom: 74 });
+ resolveHead(null);
+ await expect(result).resolves.toBeNull();
+ });
+
+ it('is none without the option, without a trigger, or with a place the plugin names', async () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
+ for (const opts of [{ slot: 'plugin-dialog' }, { slot: 'plugin-dialog', anchor: { left: 0, top: 0, right: 0, bottom: 0 } }]) {
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [opts]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toBeUndefined();
+ resolveHead(null);
+ await result;
+ }
+ forgetTrigger();
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [{ slot: 'plugin-dialog', anchor: 'trigger' }]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toBeUndefined();
+ resolveHead(null);
+ await result;
+ });
+});
diff --git a/lib/plugin-sandbox/dialog-anchor.ts b/lib/plugin-sandbox/dialog-anchor.ts
new file mode 100644
index 000000000..05fd7e81f
--- /dev/null
+++ b/lib/plugin-sandbox/dialog-anchor.ts
@@ -0,0 +1,125 @@
+// Where a plugin dialog opened with `anchor: 'trigger'` is drawn: next to
+// what the user just clicked, as a popover, instead of the centred modal.
+//
+// A plugin never sees the host's DOM, so it can't name an element. The host
+// remembers the user's last activation instead - the control under a
+// pointerdown, or the focused one on Enter / Space - and a dialog asking for
+// it within TRIGGER_WINDOW_MS takes that place. Any other key forgets it, so
+// a keyboard shortcut (s to star) never borrows an older click's place.
+// Without one the dialog is centred as before.
+
+/** A place in viewport coordinates; a point has left === right, top === bottom. */
+export interface AnchorRect {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+/** How long after the activation a dialog may still open at its place. */
+export const TRIGGER_WINDOW_MS = 2000;
+/** Space kept to the window's edges and to the anchor. */
+export const POPOVER_MARGIN = 8;
+export const POPOVER_GAP = 4;
+
+const CONTROL = 'button, a[href], [role="button"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="tab"]';
+
+interface Trigger {
+ element: Element | null;
+ rect: AnchorRect;
+ at: number;
+}
+
+let last: Trigger | null = null;
+
+function rectOf(element: Element): AnchorRect {
+ const r = element.getBoundingClientRect();
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
+}
+
+function isEmpty(r: AnchorRect): boolean {
+ return r.right - r.left <= 0 && r.bottom - r.top <= 0 && r.left === 0 && r.top === 0;
+}
+
+/**
+ * Remember an activation. A control in a menu is placed by the pointer, not
+ * by itself: the menu closes on the click, and the dialog should stand
+ * where the menu was opened, not beside an entry that is gone.
+ */
+export function noteTrigger(target: Element, point?: { x: number; y: number }, now = Date.now()): void {
+ const control = target.closest(CONTROL) ?? target;
+ const inMenu = !!control.closest('[role="menu"]');
+ if (inMenu && point) {
+ last = { element: null, rect: { left: point.x, top: point.y, right: point.x, bottom: point.y }, at: now };
+ return;
+ }
+ last = { element: control, rect: rectOf(control), at: now };
+}
+
+/** Forget the last activation (another key, tests). */
+export function forgetTrigger(): void {
+ last = null;
+}
+
+/**
+ * The place of the activation that led to a dialog opening now, or null
+ * when there was none within TRIGGER_WINDOW_MS. A control still on the page
+ * is measured again (the list may have scrolled while the plugin worked).
+ */
+export function triggerAnchor(now = Date.now()): AnchorRect | null {
+ if (!last || now - last.at > TRIGGER_WINDOW_MS) return null;
+ if (last.element?.isConnected) {
+ const r = rectOf(last.element);
+ if (!isEmpty(r)) return r;
+ }
+ return last.rect;
+}
+
+const MODIFIERS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'AltGraph', 'CapsLock']);
+
+/** Follow the user's activations on the page; returns the way to stop. */
+export function trackTriggers(doc: Document = document): () => void {
+ const onPointerDown = (e: PointerEvent | MouseEvent) => {
+ if (e.target instanceof Element) noteTrigger(e.target, { x: e.clientX, y: e.clientY });
+ };
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ const active = doc.activeElement;
+ if (active instanceof Element && active !== doc.body) noteTrigger(active);
+ else forgetTrigger();
+ } else if (!MODIFIERS.has(e.key)) {
+ forgetTrigger();
+ }
+ };
+ // Capture: a menu entry closes its menu (and is gone) during the click.
+ doc.addEventListener('pointerdown', onPointerDown, true);
+ doc.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ doc.removeEventListener('pointerdown', onPointerDown, true);
+ doc.removeEventListener('keydown', onKeyDown, true);
+ };
+}
+
+/**
+ * Top-left corner of a popover of `size` next to `anchor`: below it, lined
+ * up with its start (its end in RTL); above when it doesn't fit below and
+ * does above; always inside the viewport.
+ */
+export function placePopover(
+ anchor: AnchorRect,
+ size: { width: number; height: number },
+ viewport: { width: number; height: number },
+ rtl = false,
+): { left: number; top: number } {
+ const m = POPOVER_MARGIN;
+ let left = rtl ? anchor.right - size.width : anchor.left;
+ left = Math.min(left, viewport.width - m - size.width);
+ left = Math.max(m, left);
+
+ let top = anchor.bottom + POPOVER_GAP;
+ const above = anchor.top - POPOVER_GAP - size.height;
+ if (top + size.height > viewport.height - m && above >= m) top = above;
+ top = Math.min(top, viewport.height - m - size.height);
+ top = Math.max(m, top);
+ return { left, top };
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 76e9a50e7..1ef704fd6 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -44,6 +44,7 @@ import { MAX_KEYWORD_LENGTH } from '../keyword-nesting';
import { emailKeyFor, KEYWORD_PREFIX } from '../thread-utils';
import { DEFAULT_SEARCH_FILTERS, normalizePluginFilter } from '../jmap/search-utils';
import { awaitDialog, awaitPrompt, awaitCustomDialog, type PromptField } from './host-dialog';
+import { triggerAnchor } from './dialog-anchor';
import { fileStorage } from '../plugin-storage';
import { generateUUID } from '../utils';
import { AddressBook, ContactCard, Email, Identity } from '../jmap/types';
@@ -2192,7 +2193,7 @@ export async function dispatchApiCall(
// real app-root overlay, instead of a fixed confirm/prompt form - see
// the 'plugin-dialog' SlotName / host-dialog.ts comments for why this
// exists (a small toolbar/row slot can't show a large custom UI).
- const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record; width?: number };
+ const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record; width?: number; anchor?: unknown };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
@@ -2203,6 +2204,9 @@ export async function dispatchApiCall(
slot: opts.slot,
extraProps: (opts.extraProps && typeof opts.extraProps === 'object') ? opts.extraProps : {},
width: typeof opts.width === 'number' ? opts.width : undefined,
+ // Next to what the user just clicked, when the plugin asks and there
+ // was such a click; the place is the host's, never the plugin's.
+ anchor: opts.anchor === 'trigger' ? triggerAnchor() ?? undefined : undefined,
});
}
case 'ui.rerenderEmail': {
diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts
index c5d019787..367067cfb 100644
--- a/lib/plugin-sandbox/host-dialog.ts
+++ b/lib/plugin-sandbox/host-dialog.ts
@@ -5,6 +5,8 @@
// at a time. Prompts collect one or more (optionally masked) text fields so a
// plugin never has to fall back to the sandbox-blocked `window.prompt`.
+import type { AnchorRect } from './dialog-anchor';
+
export type DialogKind = 'confirm' | 'alert' | 'prompt' | 'custom';
export interface PromptField {
@@ -50,6 +52,12 @@ export interface DialogRequest {
extraProps?: Record;
/** Optional width override in px for the dialog box (custom kind only). */
width?: number;
+ /**
+ * Custom kind only: draw the dialog as a popover next to this place (the
+ * control whose activation led to it, see dialog-anchor.ts) instead of
+ * centred. Set by the host, never taken from the plugin.
+ */
+ anchor?: AnchorRect;
/** Called when the dialog closes with its typed result (see DialogResult). */
resolve: (result: unknown) => void;
}
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index daf0ee0db..56663f8a9 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -679,8 +679,18 @@ function buildPluginApi(manifest: PluginManifest) {
* a multi-step form, etc.) rather than a toolbar/row-sized control.
* Resolves to whatever value the slot component passes to its
* `onResult` prop, or null if the user closes the dialog without
- * calling it. No timeout - it waits for the user. */
- openDialog: (opts: { title?: string; slot: string; extraProps?: Record; width?: number }) =>
+ * calling it. No timeout - it waits for the user.
+ *
+ * `anchor: 'trigger'` draws it as a popover next to what the user
+ * activated last - an action button (message row, reader), the
+ * place a menu was opened at for a menu entry, the flag whose toggle
+ * a hook intercepted - when that was at most 2 s ago and no other
+ * key was pressed since; else centred as without it. Meant for a
+ * small menu of choices. The popover has a one-line header naming
+ * the plugin, takes `width` as the dialog does and the content's
+ * height, and closes on Escape or a click outside (null). Hosts
+ * without it ignore the option. */
+ openDialog: (opts: { title?: string; slot: string; extraProps?: Record; width?: number; anchor?: 'trigger' }) =>
callApi('ui.openDialog', [opts], 0) as Promise,
/** Re-runs the onRenderEmailBody hook for the open message (e.g. after a
* crypto plugin unlocks a key) so its body re-renders without a reload. */
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 043667986..5750523af 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -44,7 +44,7 @@ const S = (
export const SLOT_CATALOG = {
// ─── App ───────────────────────────────────────────────────
'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read', impersonated: 'account:read' }, 'a banner across the top of every page; `impersonated` is true when an admin opened the mailbox through an impersonation handoff'),
- 'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog'),
+ 'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog (with `anchor: \'trigger\'` a popover beside what the user clicked)'),
'command-palette': S('app', 'action', ['ui:commands'], {}, 'entries in the command palette (Ctrl+K)'),
// ─── Navigation ────────────────────────────────────────────
From bd05ee95382443edfd137aeb8b9d1b6b219df661 Mon Sep 17 00:00:00 2001
From: Jacob Zeitz
Date: Sat, 31 Oct 2026 13:24:34 +0100
Subject: [PATCH 05/21] feat(plugins): the calendar's task view drawn by a
plugin
A frame slot `calendar-task-view` draws the task view below Bulwark's
toolbar in place of its list (props: the toolbar's filter, the selected
calendars); `ui.openCalendar({ view })` opens a calendar view and
`ui.openTask(id)` a task in Bulwark's editor.
---
components/calendar/calendar-app.tsx | 66 ++++++++++-----
.../__tests__/plugin-task-view.test.tsx | 83 +++++++++++++++++++
components/plugins/plugin-task-view.tsx | 45 ++++++++++
.../plugin-actions-proxy-api.test.ts | 74 +++++++++++++++++
lib/plugin-sandbox/calendar-navigation.ts | 55 ++++++++++++
lib/plugin-sandbox/host-api.ts | 10 +++
lib/plugin-sandbox/open-task.ts | 52 ++++++++++++
lib/plugin-sandbox/page-navigation.ts | 11 ++-
lib/plugin-sandbox/runtime.tsx | 18 ++++
lib/plugin-sandbox/slot-layout.ts | 2 +-
lib/plugin-slot-catalog.ts | 1 +
11 files changed, 394 insertions(+), 23 deletions(-)
create mode 100644 components/plugins/__tests__/plugin-task-view.test.tsx
create mode 100644 components/plugins/plugin-task-view.tsx
create mode 100644 lib/plugin-sandbox/calendar-navigation.ts
create mode 100644 lib/plugin-sandbox/open-task.ts
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 2e237ad33..01df6c83b 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -30,6 +30,8 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice";
import { TaskToolbar } from "@/components/calendar/task-toolbar";
+import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view";
+import { onOpenTask } from "@/lib/plugin-sandbox/open-task";
import { TaskModal } from "@/components/calendar/task-modal";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
@@ -758,6 +760,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setShowTaskModal(true);
}), [closeEventPanelForTask, whenEditorsDiscarded]);
+ // A plugin drawing the task view opens a task in the editor
+ // (api.ui.openTask), as a click in Bulwark's list does.
+ useEffect(() => onOpenTask((taskId) => {
+ const task = useTaskStore.getState().tasks.find((t) => t.id === taskId);
+ if (!task) return false;
+ openEditTaskModal(task);
+ return true;
+ }), [openEditTaskModal]);
+
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
if (!client) return;
// The checkbox shows the result; only a failure needs saying.
@@ -1764,6 +1775,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return withCompany;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]);
+ // The plugin drawing the task view's list, if one offers it.
+ const pluginTaskView = usePluginTaskView();
// The tasks the task list shows; plugin buttons in its toolbar act on them.
const listedTaskIds = useMemo(
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id),
@@ -1981,27 +1994,38 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
taskIds={listedTaskIds}
compact={isMobile}
/>
- {
- // Into a calendar that takes it: the default one, else the
- // first the user may write to.
- const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
- if (!client || !target) {
- toast.error(t("notifications.task_save_error"));
- return;
- }
- taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
- .then((created) => { if (created) toast.success(t("notifications.task_created")); })
- .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
- }}
- />
+ {/* A plugin may draw the list itself (the calendar-task-view
+ slot); the toolbar above stays Bulwark's. */}
+ {pluginTaskView ? (
+
+ ) : (
+ {
+ // Into a calendar that takes it: the default one, else the
+ // first the user may write to.
+ const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
+ if (!client || !target) {
+ toast.error(t("notifications.task_save_error"));
+ return;
+ }
+ taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
+ .then((created) => { if (created) toast.success(t("notifications.task_created")); })
+ .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
+ }}
+ />
+ )}
);
}
diff --git a/components/plugins/__tests__/plugin-task-view.test.tsx b/components/plugins/__tests__/plugin-task-view.test.tsx
new file mode 100644
index 000000000..69e59a383
--- /dev/null
+++ b/components/plugins/__tests__/plugin-task-view.test.tsx
@@ -0,0 +1,83 @@
+import { cleanup, render, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import type { InstalledPlugin } from '@/lib/plugin-types';
+
+const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
+vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
+ createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
+}));
+vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
+
+const { PluginTaskView, usePluginTaskView } = await import('../plugin-task-view');
+const registry = await import('@/lib/plugin-sandbox/registry');
+
+function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions'] } = {}) {
+ const permissions = opts.permissions ?? ['ui:tasks', 'tasks:read'];
+ const plugin: InstalledPlugin = {
+ id, name: id, version: '1.0.0', author: '', description: '', type: 'ui-extension',
+ permissions, grantedPermissions: permissions, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+ registry.register({
+ plugin,
+ code: '',
+ tier: 'untrusted',
+ background: { evaluateShouldShow: vi.fn(async () => true) } as never,
+ slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }],
+ hookDisposables: [],
+ });
+}
+
+/** The task view's list area as calendar-app draws it. */
+function TaskArea({ calendarIds = ['c1', 'c2'] }: { calendarIds?: string[] }) {
+ const view = usePluginTaskView();
+ if (!view) return ;
+ return ;
+}
+
+beforeEach(() => { createSlotInstance.mockClear(); });
+
+afterEach(() => {
+ cleanup();
+ for (const p of registry.all()) registry.deregister(p.plugin.id);
+});
+
+describe('calendar-task-view', () => {
+ it('leaves Bulwark\'s list while no plugin offers the slot', () => {
+ offer('other', { slot: 'task-detail-section' });
+ const { getByTestId, container } = render();
+ expect(createSlotInstance).not.toHaveBeenCalled();
+ expect(getByTestId('bulwark-task-list')).toBeInTheDocument();
+ expect(container.querySelector('[data-plugin-task-view]')).toBeNull();
+ });
+
+ it('draws the first plugin offering it, filled, with the toolbar\'s filter and the shown calendars', async () => {
+ offer('follow-ups', { fill: true });
+ offer('second');
+ const { container, queryByTestId } = render();
+ expect(queryByTestId('bulwark-task-list')).toBeNull();
+ expect(container.querySelectorAll('[data-plugin-task-view]')).toHaveLength(1);
+ expect(container.querySelector('[data-plugin-task-view]')).toHaveAttribute('data-plugin-task-view', 'follow-ups');
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(1));
+ const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record; fill?: boolean };
+ expect(opts.slot).toBe('calendar-task-view');
+ expect(opts.fill).toBe(true);
+ expect(opts.extraProps).toEqual({ filter: 'pending', calendarIds: ['c1', 'c2'] });
+ expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
+ });
+
+ it('hands the calendars only to a plugin that reads tasks', async () => {
+ offer('looks', { permissions: ['ui:tasks'] });
+ render();
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record };
+ expect(opts.extraProps).toEqual({ filter: 'pending' });
+ });
+
+ it('lets the area scroll for a plugin that does not fill it', async () => {
+ offer('plain');
+ const { container } = render();
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto');
+ });
+});
diff --git a/components/plugins/plugin-task-view.tsx b/components/plugins/plugin-task-view.tsx
new file mode 100644
index 000000000..7684ac3a3
--- /dev/null
+++ b/components/plugins/plugin-task-view.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useMemo } from 'react';
+import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
+import type { TaskViewFilter } from '@/stores/task-store';
+import { cn } from '@/lib/utils';
+import { PluginIframeSlot } from './plugin-iframe-slot';
+
+// The `calendar-task-view` slot: a plugin drawing the calendar's task view
+// below its toolbar, in place of Bulwark's task list. One plugin at a time,
+// the first offering it (by its offer's order, then the plugins' order);
+// none, and the calendar shows its own list.
+
+/** The plugin drawing the task view, or null when none offers it. */
+export function usePluginTaskView(): { pluginId: string; fill: boolean } | null {
+ const offers = usePluginSlotOffers('calendar-task-view');
+ const first = offers[0];
+ return useMemo(() => (first ? { pluginId: first.pluginId, fill: !!first.fill } : null), [first]);
+}
+
+/**
+ * The plugin's frame in the area below the task toolbar: filled when the
+ * plugin asks for it (it scrolls inside), else the area scrolls. Its props:
+ * the toolbar's filter and the calendars ticked in the sidebar.
+ */
+export function PluginTaskView({ pluginId, fill, filter, calendarIds }: {
+ pluginId: string;
+ fill: boolean;
+ filter: TaskViewFilter;
+ calendarIds: readonly string[];
+}) {
+ // One array per set of ids, so the frame gets no update while they stay.
+ const idsKey = calendarIds.join('\u0000');
+ const ids = useMemo(() => (idsKey ? idsKey.split('\u0000') : []), [idsKey]);
+ const extraProps = useMemo(() => ({ filter, calendarIds: ids }), [filter, ids]);
+ return (
+
+
+
+ );
+}
diff --git a/lib/__tests__/plugin-actions-proxy-api.test.ts b/lib/__tests__/plugin-actions-proxy-api.test.ts
index 6202f506b..127418b5b 100644
--- a/lib/__tests__/plugin-actions-proxy-api.test.ts
+++ b/lib/__tests__/plugin-actions-proxy-api.test.ts
@@ -18,6 +18,9 @@ import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
import { allPages, resetPages } from '../plugin-sandbox/pages';
import { onPanelRequest, resetPanelRequests } from '../plugin-sandbox/panel-requests';
import { onPageNavigation, resetPageNavigation } from '../plugin-sandbox/page-navigation';
+import { onOpenTask, resetOpenTask } from '../plugin-sandbox/open-task';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useSettingsStore } from '@/stores/settings-store';
import type { StreamSink } from '../plugin-sandbox/proxy-client';
import '../plugin-sandbox/loader';
@@ -169,6 +172,77 @@ describe('day labels and pages', () => {
await expect(dispatchApiCall(paged, 'ui.openPage', ['files'])).resolves.toBe(false);
});
});
+
+ describe('ui.openTask', () => {
+ beforeEach(() => { resetOpenTask(); });
+
+ it('opens the task in the calendar\'s editor and says whether it did', async () => {
+ const seen: string[] = [];
+ onOpenTask((id) => { seen.push(id); return id === 't1'; });
+ const tasky = plugin({ id: 'ot1', permissions: ['ui:tasks'] });
+ await expect(dispatchApiCall(tasky, 'ui.openTask', ['t1'])).resolves.toBe(true);
+ await expect(dispatchApiCall(plugin({ id: 'ot1', permissions: ['tasks:read'] }), 'ui.openTask', ['gone'])).resolves.toBe(false);
+ expect(seen).toEqual(['t1', 'gone']);
+ });
+
+ it('answers false while no calendar is on screen', async () => {
+ await expect(dispatchApiCall(plugin({ id: 'ot2', permissions: ['ui:tasks'] }), 'ui.openTask', ['t1'])).resolves.toBe(false);
+ });
+
+ it('needs ui:tasks or tasks:read and a task id', async () => {
+ const seen: string[] = [];
+ onOpenTask((id) => { seen.push(id); return true; });
+ await expect(dispatchApiCall(plugin({ id: 'ot3', permissions: ['calendar:read'] }), 'ui.openTask', ['t1'])).rejects.toThrow(/lacks permission/);
+ await expect(dispatchApiCall(plugin({ id: 'ot3', permissions: ['ui:tasks'] }), 'ui.openTask', [42])).rejects.toThrow(/taskId/);
+ expect(seen).toEqual([]);
+ });
+ });
+
+ describe('ui.openCalendar', () => {
+ const seen: string[] = [];
+ beforeEach(() => {
+ resetPageNavigation();
+ seen.length = 0;
+ useSettingsStore.setState({ enableCalendarTasks: true });
+ useCalendarStore.setState({ viewMode: 'month' });
+ });
+
+ it('opens the task view: the calendar\'s view set, the tab moved to its permalink', async () => {
+ onPageNavigation((href) => { seen.push(href); });
+ await expect(dispatchApiCall(plugin({ id: 'tv1', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(true);
+ expect(useCalendarStore.getState().viewMode).toBe('tasks');
+ await expect(dispatchApiCall(plugin({ id: 'tv1', permissions: ['calendar:read'] }), 'ui.openCalendar', [{ view: 'week' }])).resolves.toBe(true);
+ expect(seen).toEqual(['/calendar/tasks', '/calendar/week']);
+ });
+
+ it('needs the view\'s permission and refuses any other view', async () => {
+ onPageNavigation((href) => { seen.push(href); });
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['calendar:read'] }), 'ui.openCalendar', [{ view: 'tasks' }])).rejects.toThrow(/lacks permission/);
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'month' }])).rejects.toThrow(/lacks permission/);
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'settings' }])).rejects.toThrow(/view must be one of/);
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'tasks', date: 'x' }])).rejects.toThrow(/unknown property/);
+ expect(seen).toEqual([]);
+ expect(useCalendarStore.getState().viewMode).toBe('month');
+ });
+
+ it('answers false while nothing can move the tab or the user has tasks off', async () => {
+ const tasky = plugin({ id: 'tv3', permissions: ['ui:tasks'] });
+ await expect(dispatchApiCall(tasky, 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(false);
+ onPageNavigation((href) => { seen.push(href); });
+ useSettingsStore.setState({ enableCalendarTasks: false });
+ await expect(dispatchApiCall(tasky, 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(false);
+ expect(seen).toEqual([]);
+ expect(useCalendarStore.getState().viewMode).toBe('month');
+ });
+
+ it('moves the tab at most 10 times a minute', async () => {
+ onPageNavigation((href) => { seen.push(href); });
+ const busy = plugin({ id: 'tv4', permissions: ['ui:tasks'] });
+ for (let i = 0; i < 10; i++) await dispatchApiCall(busy, 'ui.openCalendar', [{ view: 'tasks' }]);
+ await expect(dispatchApiCall(busy, 'ui.openCalendar', [{ view: 'tasks' }])).rejects.toThrow(/a minute/);
+ expect(seen).toHaveLength(10);
+ });
+ });
});
describe('proxy.fetch', () => {
diff --git a/lib/plugin-sandbox/calendar-navigation.ts b/lib/plugin-sandbox/calendar-navigation.ts
new file mode 100644
index 000000000..c69fc4a36
--- /dev/null
+++ b/lib/plugin-sandbox/calendar-navigation.ts
@@ -0,0 +1,55 @@
+// A plugin moving the tab to a view of the calendar (api.ui.openCalendar),
+// e.g. a count of due tasks opening the task view, which a plugin may draw
+// itself (the calendar-task-view slot). The view is set in the calendar's
+// store first, so a calendar already on screen switches in place, then the
+// tab moves to the view's permalink (/calendar/) through the same
+// router handler as api.ui.openPage. Only the calendar's own views: a
+// plugin can never send the tab anywhere else.
+
+import type { InstalledPlugin, Permission } from '../plugin-types';
+import type { CalendarViewMode } from '@/stores/calendar-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { buildCalendarPath } from '../deep-links';
+import { readObject, withinRate } from './host-api-input';
+import { pluginHasPermission } from './permissions';
+import { canNavigateInApp, navigateInApp } from './page-navigation';
+
+const METHOD = 'ui.openCalendar';
+
+/** The views a plugin may open, each with the permissions of which one is enough. */
+const VIEW_PERMISSIONS: Readonly> = {
+ month: ['ui:calendar-action', 'calendar:read'],
+ week: ['ui:calendar-action', 'calendar:read'],
+ day: ['ui:calendar-action', 'calendar:read'],
+ agenda: ['ui:calendar-action', 'calendar:read'],
+ tasks: ['ui:tasks', 'tasks:read'],
+};
+
+/** At most this many moves a minute, so a plugin cannot keep pulling the tab away. */
+export const CALENDAR_OPENS_PER_MINUTE = 10;
+
+/**
+ * Checks the request and opens the view: true when the tab went there,
+ * false when nothing could take it (no router yet, or the task view while
+ * the user has tasks turned off), or an error the plugin sees.
+ */
+export function requestCalendarView(plugin: InstalledPlugin, input: unknown): boolean {
+ const data = readObject(input, METHOD, 'options', ['view']);
+ const view = data.view;
+ if (typeof view !== 'string' || !Object.prototype.hasOwnProperty.call(VIEW_PERMISSIONS, view)) {
+ throw new Error(`${METHOD}: view must be one of ${Object.keys(VIEW_PERMISSIONS).join(', ')}`);
+ }
+ const mode = view as CalendarViewMode;
+ const perms = VIEW_PERMISSIONS[mode];
+ if (!perms.some((perm) => pluginHasPermission(plugin, perm))) {
+ throw new Error(`Plugin "${plugin.id}" lacks permission "${perms.join('" or "')}"`);
+ }
+ if (mode === 'tasks' && !useSettingsStore.getState().enableCalendarTasks) return false;
+ if (!canNavigateInApp()) return false;
+ if (!withinRate('open-calendar', plugin.id, CALENDAR_OPENS_PER_MINUTE)) {
+ throw new Error(`${METHOD}: at most ${CALENDAR_OPENS_PER_MINUTE} a minute`);
+ }
+ useCalendarStore.getState().setViewMode(mode);
+ return navigateInApp(buildCalendarPath({ view: mode, date: null }));
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 76e9a50e7..6ab1f2b0c 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -69,6 +69,8 @@ import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAcco
import { setSectionTitle } from './section-titles';
import { requestPanel } from './panel-requests';
import { requestPageNavigation } from './page-navigation';
+import { requestCalendarView } from './calendar-navigation';
+import { requestOpenTask } from './open-task';
import { requestOpenEmail } from './open-email';
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerGetSnapshot, doComposerReadAttachment, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
import { wkdAllowed } from '../outgoing-hosts';
@@ -254,6 +256,12 @@ export const PERM_PER_METHOD: Readonly> = {
'ui.setPages': 'ui:app-page',
// Move the tab to one of the plugin's own pages (never anywhere else).
'ui.openPage': 'ui:app-page',
+ // A view of the calendar; the view's own permission is checked per call
+ // (the task view: ui:tasks or tasks:read; the others: ui:calendar-action
+ // or calendar:read).
+ 'ui.openCalendar': null,
+ // A task in Bulwark's task editor; ui:tasks or tasks:read, checked per call.
+ 'ui.openTask': null,
// Opening a message shows its content, as reading it does.
'ui.openEmail': 'email:read',
'ui.setSectionTitle': 'ui:sidebar-widget',
@@ -2035,6 +2043,8 @@ export async function dispatchApiCall(
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
case 'ui.openPage': return requestPageNavigation(plugin.id, args[0], args[1]);
+ case 'ui.openCalendar': return requestCalendarView(plugin, args[0]);
+ case 'ui.openTask': return requestOpenTask(plugin, args[0]);
case 'ui.openEmail': return requestOpenEmail(plugin, args[0]);
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
case 'ui.setPanel': {
diff --git a/lib/plugin-sandbox/open-task.ts b/lib/plugin-sandbox/open-task.ts
new file mode 100644
index 000000000..ab0f17a39
--- /dev/null
+++ b/lib/plugin-sandbox/open-task.ts
@@ -0,0 +1,52 @@
+// A plugin opening one of the user's tasks in Bulwark's task editor
+// (api.ui.openTask): what a click on a task in the calendar's task list
+// does. A plugin drawing the task view itself (the calendar-task-view slot)
+// lists the tasks but cannot open one otherwise. The calendar opens it
+// through the handler it sets while it is on screen; without one (another
+// page) the request resolves to false. A task the calendar has not loaded
+// resolves to false too.
+
+import type { InstalledPlugin, Permission } from '../plugin-types';
+import { readId, withinRate } from './host-api-input';
+import { pluginHasPermission } from './permissions';
+
+const METHOD = 'ui.openTask';
+
+/** Either one lets a plugin open a task. */
+export const OPEN_TASK_PERMISSIONS: readonly Permission[] = ['ui:tasks', 'tasks:read'];
+
+/** At most this many a minute. */
+export const TASK_OPENS_PER_MINUTE = 20;
+
+/** Opens the task in the editor: true when it opened, false when the calendar does not have it. */
+export type OpenTaskHandler = (taskId: string) => boolean;
+
+// The calendars on screen, the latest mounted last; it takes the request.
+const handlers: OpenTaskHandler[] = [];
+
+export function onOpenTask(handler: OpenTaskHandler): () => void {
+ handlers.push(handler);
+ return () => {
+ const at = handlers.lastIndexOf(handler);
+ if (at >= 0) handlers.splice(at, 1);
+ };
+}
+
+/** Checks the request and opens the task: true when the editor opened, else false. */
+export function requestOpenTask(plugin: InstalledPlugin, taskId: unknown): boolean {
+ if (!OPEN_TASK_PERMISSIONS.some((perm) => pluginHasPermission(plugin, perm))) {
+ throw new Error(`Plugin "${plugin.id}" lacks permission "${OPEN_TASK_PERMISSIONS.join('" or "')}"`);
+ }
+ const id = readId(taskId, METHOD, 'taskId');
+ const handler = handlers.at(-1);
+ if (!handler) return false;
+ if (!withinRate('open-task', plugin.id, TASK_OPENS_PER_MINUTE)) {
+ throw new Error(`${METHOD}: at most ${TASK_OPENS_PER_MINUTE} a minute`);
+ }
+ return handler(id);
+}
+
+/** Test helper. */
+export function resetOpenTask(): void {
+ handlers.length = 0;
+}
diff --git a/lib/plugin-sandbox/page-navigation.ts b/lib/plugin-sandbox/page-navigation.ts
index 312f7b733..8b64c0e0f 100644
--- a/lib/plugin-sandbox/page-navigation.ts
+++ b/lib/plugin-sandbox/page-navigation.ts
@@ -39,7 +39,16 @@ export function pagePathHref(pluginId: string, pageId: unknown, path: unknown):
/** Checks the request and moves the tab: true when it went, false when nothing could take it. */
export function requestPageNavigation(pluginId: string, pageId: unknown, path: unknown): boolean {
- const href = pagePathHref(pluginId, pageId, path);
+ return navigateInApp(pagePathHref(pluginId, pageId, path));
+}
+
+/** Whether something can move the tab now (the router is mounted). */
+export function canNavigateInApp(): boolean {
+ return handler !== null;
+}
+
+/** Moves the tab to an in-app href the host built: false while nothing can. */
+export function navigateInApp(href: string): boolean {
if (!handler) return false;
handler(href);
return true;
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index daf0ee0db..97dae8229 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -741,6 +741,24 @@ function buildPluginApi(manifest: PluginManifest) {
*/
openPage: (pageId: string, path?: string) =>
callApi('ui.openPage', [pageId, path]) as Promise,
+ /**
+ * Moves this tab to a view of the calendar: 'month', 'week', 'day',
+ * 'agenda' (needs ui:calendar-action or calendar:read) or 'tasks', the
+ * task view (needs ui:tasks or tasks:read), which a plugin offering the
+ * calendar-task-view slot draws. Resolves to false when the app could
+ * not take it (not ready yet, or the user has tasks turned off). At
+ * most 10 a minute.
+ */
+ openCalendar: (options: { view: 'month' | 'week' | 'day' | 'agenda' | 'tasks' }) =>
+ callApi('ui.openCalendar', [options]) as Promise,
+ /**
+ * Opens one of the user's tasks (an id from tasks.list) in Bulwark's
+ * task editor, as a click on it in the calendar's task list does: for
+ * a plugin drawing the task view (calendar-task-view). Resolves to
+ * false when the calendar is not on screen or has not loaded the task.
+ * Needs ui:tasks or tasks:read; at most 20 a minute.
+ */
+ openTask: (taskId: string) => callApi('ui.openTask', [taskId]) as Promise,
/**
* Opens a message in the reader of this tab, as a click on it in the
* list does: in place on the mail page, in an email tab in the Pro
diff --git a/lib/plugin-sandbox/slot-layout.ts b/lib/plugin-sandbox/slot-layout.ts
index d3dedb2e6..6cb866bcf 100644
--- a/lib/plugin-sandbox/slot-layout.ts
+++ b/lib/plugin-sandbox/slot-layout.ts
@@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null {
* can pin controls at the bottom, like a chat input. Elsewhere a frame grows
* with its content.
*/
-export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page'];
+export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page', 'calendar-task-view'];
export function isFillableSlot(slot: string): boolean {
return FILLABLE_SLOTS.includes(slot);
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 043667986..4ddcb7e53 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -106,6 +106,7 @@ export const SLOT_CATALOG = {
// ─── Tasks ─────────────────────────────────────────────────
'task-detail-section': S('tasks', 'frame', ['ui:tasks'], { task: 'tasks:read' }, 'a section in a task\'s details'),
'task-list-actions': S('tasks', 'action', ['ui:tasks'], { taskIds: 'tasks:read' }, 'buttons in the task list toolbar', true),
+ 'calendar-task-view': S('tasks', 'frame', ['ui:tasks'], { filter: null, calendarIds: 'tasks:read' }, 'the calendar\'s task view (Calendar › Tasks) below its toolbar, in place of Bulwark\'s task list; the toolbar (filter, new task, task-list-actions) stays; one plugin at a time, the first offering it; `filter` is the toolbar\'s filter ("all" | "pending" | "completed" | "overdue"), `calendarIds` the calendars ticked in the sidebar; `slots[\'calendar-task-view\'].fill = true` makes the frame as tall as the area below the toolbar, so the plugin scrolls inside it; api.ui.openCalendar({ view: \'tasks\' }) opens the view'),
// ─── Files ─────────────────────────────────────────────────
'file-actions': S('files', 'action', ['ui:files'], { files: 'files:read' }, 'entries in a file\'s menu and the selection toolbar'),
From 48f0b94ee68c1ebad6f3e012b307b0e006372640 Mon Sep 17 00:00:00 2001
From: Helmut Wolf <3902045+shadow1runner@users.noreply.github.com>
Date: Sun, 1 Nov 2026 20:13:48 +0100
Subject: [PATCH 06/21] feat: send a template's data: images as inline parts
#1490
Images in a template arrive as data: URIs (an imported templates file, or a
template saved from a message with a pasted image) and went out as they
were, so Gmail and Outlook did not show them. Inserting a template now
uploads each one like a pasted image and tags it with the cid, so the send
path turns it into a cid: part in multipart/related.
---
.../composer-template-images.test.tsx | 247 ++++++++++++++++++
components/email/email-composer.tsx | 74 +++---
lib/__tests__/template-utils.test.ts | 49 ++++
lib/template-utils.ts | 48 ++++
4 files changed, 382 insertions(+), 36 deletions(-)
create mode 100644 components/email/__tests__/composer-template-images.test.tsx
diff --git a/components/email/__tests__/composer-template-images.test.tsx b/components/email/__tests__/composer-template-images.test.tsx
new file mode 100644
index 000000000..ccd146dc5
--- /dev/null
+++ b/components/email/__tests__/composer-template-images.test.tsx
@@ -0,0 +1,247 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Editor } from '@tiptap/core';
+import { EmailComposer } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+
+// The real composer around the real editor: a template's data: image goes
+// out as an inline cid: part, not as a data: URI (#1490).
+
+// A 1×1 PNG.
+const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+
+const TEMPLATE = vi.hoisted(() => ({
+ id: 'tpl-1',
+ name: 'Offer',
+ category: '',
+ subject: 'Your offer',
+ body: '
${openEnded}`)).toBe(true);
});
});
+
+describe('embedTemplateImages', () => {
+ // A 1×1 PNG.
+ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+ const DATA = `data:image/png;base64,${PNG}`;
+ const uploader = () => {
+ let n = 0;
+ return vi.fn(async (_file: File) => ({ cid: `img${++n}@webmail` }));
+ };
+
+ it('uploads a data: image and tags it with the cid', async () => {
+ const upload = uploader();
+ const html = await embedTemplateImages(`
Plan
`, upload);
+ expect(upload).toHaveBeenCalledTimes(1);
+ const file = upload.mock.calls[0][0];
+ expect(file).toMatchObject({ name: 'image-1.png', type: 'image/png' });
+ expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from(atob(PNG), (c) => c.charCodeAt(0)));
+ const img = new DOMParser().parseFromString(html, 'text/html').querySelector('img')!;
+ expect(img.getAttribute('data-cid')).toBe('img1@webmail');
+ expect(img.getAttribute('src')).toBe(DATA);
+ expect(img.getAttribute('alt')).toBe('Floor plan');
+ });
+
+ it('uploads an image shown twice once', async () => {
+ const upload = uploader();
+ const html = await embedTemplateImages(``, upload);
+ expect(upload).toHaveBeenCalledTimes(1);
+ expect(html.match(/data-cid="img1@webmail"/g)).toHaveLength(2);
+ });
+
+ it('replaces a cid the template was saved with', async () => {
+ const html = await embedTemplateImages(``, uploader());
+ expect(html).toContain('data-cid="img1@webmail"');
+ expect(html).not.toContain('old@webmail');
+ });
+
+ it('leaves remote, cid: and non-raster images alone', async () => {
+ const upload = uploader();
+ const html = '';
+ expect(await embedTemplateImages(html, upload)).toBe(html);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it('keeps an image as it is when its upload fails', async () => {
+ const html = ``;
+ expect(await embedTemplateImages(html, async () => null)).toBe(html);
+ });
+});
diff --git a/lib/template-utils.ts b/lib/template-utils.ts
index 95a5ad191..edfb03588 100644
--- a/lib/template-utils.ts
+++ b/lib/template-utils.ts
@@ -141,6 +141,54 @@ export function composeBodyHasUserContent(prevHtml: string): boolean {
return doc.body.querySelector('img, table, hr, [data-quoted-html]') !== null;
}
+const DATA_IMAGE = /^data:(image\/(?:png|jpe?g|gif|webp|bmp|avif));base64,([a-z0-9+/=\s]+)$/i;
+
+function dataImageFile(src: string, name: string): File | null {
+ const match = DATA_IMAGE.exec(src);
+ if (!match) return null;
+ try {
+ const binary = atob(match[2].replace(/\s+/g, ''));
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
+ const type = match[1].toLowerCase();
+ return new File([bytes], `${name}.${type.slice('image/'.length).replace('jpeg', 'jpg')}`, { type });
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * A template's images arrive as data: URIs (an imported templates file, or a
+ * template saved from a message with a pasted image). Sent as they are, Gmail
+ * and Outlook don't show them, so hand each one to `upload` - the composer's
+ * pasted-image upload - and tag it with the returned cid; the send path then
+ * turns it into a cid: part (#1490). An image whose upload fails is left as is.
+ */
+export async function embedTemplateImages(
+ html: string,
+ upload: (file: File) => Promise<{ cid: string } | null>,
+): Promise {
+ if (!/]*\bsrc\s*=\s*["']?\s*data:image\//i.test(html)) return html;
+ const doc = new DOMParser().parseFromString(`${html}`, 'text/html');
+ const images = Array.from(doc.querySelectorAll('img[src]'))
+ .map((img) => ({ img, src: img.getAttribute('src')!.trim() }))
+ .filter(({ src }) => DATA_IMAGE.test(src));
+ if (images.length === 0) return html;
+ // The same image shown twice goes out as one part.
+ const sources = Array.from(new Set(images.map(({ src }) => src)));
+ const cids = new Map(await Promise.all(sources.map(async (src, i) => {
+ const file = dataImageFile(src, `image-${i + 1}`);
+ return [src, file ? (await upload(file))?.cid : undefined] as const;
+ })));
+ let changed = false;
+ for (const { img, src } of images) {
+ const cid = cids.get(src);
+ if (!cid) continue;
+ img.setAttribute('data-cid', cid);
+ changed = true;
+ }
+ return changed ? doc.body.innerHTML : html;
+}
+
function sanitizeText(value: unknown): string {
return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG);
}
From 1f8b1f4533ab02f34be8faae1c739500e5ed4d9f Mon Sep 17 00:00:00 2001
From: daluchen
Date: Fri, 30 Oct 2026 17:45:04 +0800
Subject: [PATCH 07/21] feat: add signature auto-insert toggles, off by default
Automatic signature insertion was unconditional: any identity with a
signature got it embedded in new mail and appended to replies, forwards
and quick replies. Users who want a signature only in specific contexts
had no way to opt out.
Add two per-account synced settings, both defaulting to off:
- signatureNewMailEnabled: insert the signature in new messages (and
re-opened drafts, which open in compose mode)
- signatureReplyForwardEnabled: append it in replies, forwards and the
quick reply box
With a toggle off the composer neither embeds the signature into the
body, nor appends one at send or draft-save time, nor shows the
read-only preview under the editor - the preview would promise a
signature that never goes out. A signature pasted in by hand is never
stripped (bodyCarriesSignature still short-circuits the append).
Also flip signatureSeparatorEnabled's default from true to false: the
RFC 3676 "-- " delimiter surprised users more than it helped. Only the
default changes - no migration, so users who already set it keep their
choice.
Co-Authored-By: Claude Code
---
.../email/__tests__/composer-a11y.test.tsx | 2 +
.../__tests__/composer-format-toggle.test.tsx | 2 +
.../composer-from-override-envelope.test.tsx | 2 +
.../composer-identity-signature.test.tsx | 39 +++++++++++++++++++
.../__tests__/composer-org-signature.test.tsx | 2 +
...composer-reopened-draft-signature.test.tsx | 2 +
.../composer-reply-signature.test.tsx | 2 +
.../composer-template-reply.test.tsx | 2 +
components/email/email-composer.tsx | 36 ++++++++++++-----
components/mail/mail-app.tsx | 3 +-
components/settings/composing-settings.tsx | 16 ++++++++
lib/__tests__/settings-search.test.ts | 6 +++
lib/outgoing/quick-reply.ts | 14 ++++++-
lib/settings-search.ts | 5 ++-
locales/ar/common.json | 8 ++++
locales/ca/common.json | 8 ++++
locales/cs/common.json | 8 ++++
locales/da/common.json | 8 ++++
locales/de/common.json | 8 ++++
locales/en/common.json | 8 ++++
locales/es/common.json | 8 ++++
locales/et/common.json | 8 ++++
locales/fa/common.json | 8 ++++
locales/fr/common.json | 8 ++++
locales/he/common.json | 8 ++++
locales/hu/common.json | 8 ++++
locales/it/common.json | 8 ++++
locales/ja/common.json | 8 ++++
locales/ko/common.json | 8 ++++
locales/lv/common.json | 8 ++++
locales/mn/common.json | 8 ++++
locales/nb/common.json | 8 ++++
locales/nl/common.json | 8 ++++
locales/pl/common.json | 8 ++++
locales/pt/common.json | 8 ++++
locales/ro/common.json | 8 ++++
locales/ru/common.json | 8 ++++
locales/sk/common.json | 8 ++++
locales/th/common.json | 8 ++++
locales/tr/common.json | 8 ++++
locales/uk/common.json | 8 ++++
locales/vi/common.json | 8 ++++
locales/zh-TW/common.json | 8 ++++
locales/zh/common.json | 8 ++++
stores/__tests__/settings-store-sync.test.ts | 14 +++++++
stores/settings-store.ts | 8 +++-
46 files changed, 380 insertions(+), 15 deletions(-)
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index 3a9d3f39f..e69492e89 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -49,6 +49,8 @@ const settings = vi.hoisted(() => ({
emptySubjectWarningEnabled: false,
sendDelaySeconds: 0,
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
sendConfirmation: false,
diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx
index 28f3f029c..5df61c004 100644
--- a/components/email/__tests__/composer-format-toggle.test.tsx
+++ b/components/email/__tests__/composer-format-toggle.test.tsx
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx
index e8cd2f9f6..c6d39338e 100644
--- a/components/email/__tests__/composer-from-override-envelope.test.tsx
+++ b/components/email/__tests__/composer-from-override-envelope.test.tsx
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
diff --git a/components/email/__tests__/composer-identity-signature.test.tsx b/components/email/__tests__/composer-identity-signature.test.tsx
index 84a6c3a98..b0d9a0093 100644
--- a/components/email/__tests__/composer-identity-signature.test.tsx
+++ b/components/email/__tests__/composer-identity-signature.test.tsx
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -257,3 +259,40 @@ describe('composer signature follows the picked identity', () => {
expect(sent.body).toBe('Plain draft');
});
});
+
+describe('signature auto-insert toggles', () => {
+ afterEach(() => {
+ useSettingsStore.setState({
+ plainTextMode: false,
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
+ });
+ vi.clearAllMocks();
+ });
+
+ it('keeps a new message free of the signature when the new-mail toggle is off', async () => {
+ useSettingsStore.setState({ plainTextMode: true, signatureNewMailEnabled: false });
+ const onSend = vi.fn();
+ render();
+
+ // Not embedded into the editable body…
+ await waitFor(() => expect(textarea().value).toBe('Draft text'));
+ // …and not appended at send time either.
+ const sent = await send(onSend);
+ expect(sent.body).toBe('Draft text');
+ });
+
+ it('sends a reply without appending the signature when the reply toggle is off', async () => {
+ useSettingsStore.setState({ signatureReplyForwardEnabled: false });
+ const onSend = vi.fn();
+ render();
+
+ // No read-only signature preview under the editor: it would promise a
+ // signature that never goes out.
+ expect(document.body.textContent).not.toContain('Alice');
+
+ const sent = await send(onSend);
+ expect(sent.htmlBody).not.toContain('Alice');
+ expect(sent.body).not.toContain('Alice');
+ });
+});
diff --git a/components/email/__tests__/composer-org-signature.test.tsx b/components/email/__tests__/composer-org-signature.test.tsx
index e6e76afa0..ee494c68f 100644
--- a/components/email/__tests__/composer-org-signature.test.tsx
+++ b/components/email/__tests__/composer-org-signature.test.tsx
@@ -136,6 +136,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
diff --git a/components/email/__tests__/composer-reopened-draft-signature.test.tsx b/components/email/__tests__/composer-reopened-draft-signature.test.tsx
index 3ddd45a2f..9e8aae0f9 100644
--- a/components/email/__tests__/composer-reopened-draft-signature.test.tsx
+++ b/components/email/__tests__/composer-reopened-draft-signature.test.tsx
@@ -96,6 +96,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
diff --git a/components/email/__tests__/composer-reply-signature.test.tsx b/components/email/__tests__/composer-reply-signature.test.tsx
index 9205d4c35..eef1cd74a 100644
--- a/components/email/__tests__/composer-reply-signature.test.tsx
+++ b/components/email/__tests__/composer-reply-signature.test.tsx
@@ -123,6 +123,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx
index f804dde67..44ee3ff12 100644
--- a/components/email/__tests__/composer-template-reply.test.tsx
+++ b/components/email/__tests__/composer-template-reply.test.tsx
@@ -107,6 +107,8 @@ vi.mock('@/stores/settings-store', () => {
recipientMentionsEnabled: false,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
externalContentPolicy: 'block',
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index dbab1a316..23d4d8b93 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -547,6 +547,13 @@ export function EmailComposer({
const updateSetting = useSettingsStore((state) => state.updateSetting);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
+ const signatureNewMailEnabled = useSettingsStore((state) => state.signatureNewMailEnabled);
+ const signatureReplyForwardEnabled = useSettingsStore((state) => state.signatureReplyForwardEnabled);
+ // Whether automatic signature insertion is on for this composer mode: new
+ // mail (and re-opened drafts, which open in compose mode) follow the
+ // new-mail toggle, replies and forwards the reply/forward one. What the
+ // body already carries is never stripped - only the insert/append is gated.
+ const autoSignatureEnabled = mode === 'compose' ? signatureNewMailEnabled : signatureReplyForwardEnabled;
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
// A draft belongs to the account the composer was opened in. Switching
@@ -622,12 +629,13 @@ export function EmailComposer({
const shouldEmbedSignatureAboveQuote =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote' &&
+ signatureReplyForwardEnabled &&
hasInitialSignature;
// New-mail composes always embed the signature into the editor body so it's
// editable/removable (the previous read-only preview below the editor was
// never spec-correct - see #329). Compose mode also ignores any leftover
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
- const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
+ const shouldEmbedSignatureInNewMail = mode === 'compose' && signatureNewMailEnabled && hasInitialSignature;
// Format a single EmailAddress for display in the composer input
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
@@ -1317,6 +1325,9 @@ export function EmailComposer({
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
if (!isReplyLike && mode !== 'compose') return;
if (isReplyLike && signaturePosition !== 'above_quote') return;
+ // With automatic insertion off the body never got a signature spliced
+ // in, so there is nothing to swap.
+ if (!autoSignatureEnabled) return;
if (plainTextMode) {
// A new plain-text message carries the signature at the end of the
@@ -1358,7 +1369,7 @@ export function EmailComposer({
// would re-run on unrelated identity-field changes and re-splice the
// signature into the live editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
+ }, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]);
const openScheduleDialog = useCallback(() => {
setShowScheduleDialog(true);
@@ -2658,7 +2669,7 @@ export function EmailComposer({
// below the editor - was saved without it (#823). Embed the *marked-up*
// form so re-opening the draft round-trips the signature as one block and
// signatureAlreadyInBody sees it instead of appending a second copy.
- const draftSignatureHtml = signatureAlreadyInBody
+ const draftSignatureHtml = signatureAlreadyInBody || !autoSignatureEnabled
? ''
: buildEmbeddedSignatureHtml(signatureIdentity, {
embed: true,
@@ -2666,7 +2677,7 @@ export function EmailComposer({
});
const draftHtmlBody = plainTextMode ? undefined : `${body}${draftSignatureHtml}`;
const draftTextBody = plainTextMode
- ? (signatureAlreadyInBody
+ ? (signatureAlreadyInBody || !autoSignatureEnabled
? body
: appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled }))
: htmlToPlainText(draftHtmlBody!, imageAltText);
@@ -3603,9 +3614,14 @@ export function EmailComposer({
// above-quote replies, a re-opened draft) `signatureAlreadyInBody` is set
// and the trailing append below is skipped so we don't duplicate it.
+ // Whether automatic signature insertion is on for this composer mode
+ // (component-level `autoSignatureEnabled` - see signatureAlreadyInBody).
+ // What the body already carries is never stripped - only the append is
+ // gated.
+
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
- if (signatureAlreadyInBody) return '';
+ if (signatureAlreadyInBody || !autoSignatureEnabled) return '';
const sep = wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? `
-- ` : `
`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
@@ -3622,8 +3638,8 @@ export function EmailComposer({
// In plain text mode, send text/plain only (no HTML body)
const signatureOpts = { separator: signatureSeparatorEnabled };
const builtTextBody = plainTextMode
- ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
- : (signatureAlreadyInBody ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts));
+ ? (signatureAlreadyInBody || !autoSignatureEnabled ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
+ : (signatureAlreadyInBody || !autoSignatureEnabled ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts));
// Before any blobId is read: forwarded parts may still sit on the account
// that holds the original message.
@@ -4775,8 +4791,10 @@ export function EmailComposer({
{/* Hide the visual signature preview when the signature has already been
embedded into the body (compose, above-quote replies, re-opened
- drafts) - it would otherwise read as a second signature. */}
- {signatureAlreadyInBody ? null
+ drafts) - it would otherwise read as a second signature. Also when
+ automatic insertion is off: nothing will be appended at send time,
+ so a preview would promise a signature that never goes out. */}
+ {signatureAlreadyInBody || !autoSignatureEnabled ? null
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 33ae06070..478819513 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -4118,6 +4118,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
signatureIdentity,
sendingIdentity,
separator: settings.signatureSeparatorEnabled,
+ // The user's reply/forward signature toggle: off sends the bare text.
+ insertSignature: settings.signatureReplyForwardEnabled,
to: recipients,
fromEmail: headerFromEmail,
identityId: sendingIdentity?.id,
@@ -4141,7 +4143,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
if (!prepared) return false;
const { html: finalHtmlBody, text: finalBody } = prepared;
-
const originalEmailId = selectedEmail.id;
const originalSource = emailWriteSource(selectedEmail, client);
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx
index becf7296b..a4227d6f8 100644
--- a/components/settings/composing-settings.tsx
+++ b/components/settings/composing-settings.tsx
@@ -36,6 +36,8 @@ export function ComposingSettings() {
mentionAddsRecipientTo,
sendDelaySeconds,
signaturePosition,
+ signatureNewMailEnabled,
+ signatureReplyForwardEnabled,
signatureSeparatorEnabled,
requestReadReceiptDefault,
readReceiptResponse,
@@ -124,6 +126,20 @@ export function ComposingSettings() {
/>
+
+ updateSetting('signatureNewMailEnabled', checked)}
+ />
+
+
+
+ updateSetting('signatureReplyForwardEnabled', checked)}
+ />
+
+
{
expect(resultsFor('composing', 'reply signature').map((r) => r.label)).toContain('Signature position');
});
+ it('finds the newer signature settings on their tabs', () => {
+ expect(labelsFor('composing')).toContain('Signature on new mail');
+ expect(labelsFor('composing')).toContain('Signature on replies & forwards');
+ expect(labelsFor('composing')).toContain('Undo send / send delay');
+ });
+
it('marks the rows the Advanced switch hides', () => {
const advanced = settingsSubResults(en, 'message_list', 'Bulwark').filter((r) => r.advanced).map((r) => r.label);
expect(advanced).toContain('Emails per page');
diff --git a/lib/outgoing/quick-reply.ts b/lib/outgoing/quick-reply.ts
index 142022ddf..fc9104eac 100644
--- a/lib/outgoing/quick-reply.ts
+++ b/lib/outgoing/quick-reply.ts
@@ -35,12 +35,19 @@ export interface QuickReplyQuestions {
* (onBeforeEmailSend) run on it too, after the organisation signature and
* before the disclaimer, as in the composer; `onSendCheckStopped` hears
* why one stopped it.
+ *
+ * `insertSignature` is the user's reply/forward signature toggle: false
+ * sends the bare typed text, with no signature of the user's own. The
+ * organisation signature and disclaimer still apply - they are policy,
+ * not the user's choice.
*/
export async function prepareQuickReply(input: {
body: string;
signatureIdentity: Identity | null | undefined;
sendingIdentity?: Identity | null;
separator: boolean;
+ /** The user's signature auto-insert toggle for replies/forwards. */
+ insertSignature?: boolean;
to: string[];
fromEmail?: string;
identityId?: string;
@@ -66,7 +73,10 @@ export async function prepareQuickReply(input: {
}
const signatureIdentity = currentSignatureIdentity(input.signatureIdentity, input.sendingIdentity ?? undefined);
- const text = appendPlainTextSignature(body, signatureIdentity, { separator });
+ const insertSignature = input.insertSignature !== false;
+ const text = insertSignature
+ ? appendPlainTextSignature(body, signatureIdentity, { separator })
+ : body;
// When the identity has an HTML signature, send a matching HTML body so the
// signature keeps its formatting; appendPlainTextSignature would otherwise
@@ -77,7 +87,7 @@ export async function prepareQuickReply(input: {
.replace(//g, '>')
.replace(/\n/g, ' ');
- const html = signatureIdentity?.htmlSignature?.trim()
+ const html = insertSignature && signatureIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`
${escapedBody}
`, signatureIdentity, { separator })
: undefined;
diff --git a/lib/settings-search.ts b/lib/settings-search.ts
index d85ac11bf..e94207411 100644
--- a/lib/settings-search.ts
+++ b/lib/settings-search.ts
@@ -247,6 +247,8 @@ export const tabSearchEntries: Record = {
{ label: 'settings.email_behavior.quick_reply.label' },
{ label: 'settings.email_behavior.auto_select_reply_identity.label' },
{ label: 'settings.email_behavior.signature_position.label' },
+ { label: 'settings.email_behavior.signature_new_mail.label' },
+ { label: 'settings.email_behavior.signature_reply_forward.label' },
{ label: 'settings.email_behavior.signature_separator.label', advanced: true },
{ label: 'settings.composing_page.sections.sending' },
{ label: 'settings.email_behavior.send_delay.label' },
@@ -358,8 +360,7 @@ export const tabSearchEntries: Record = {
{ label: 'settings.advanced.refresh_cache.label' },
{ label: 'settings.advanced.reset_settings.label' },
{ label: 'settings.advanced.debug_mode.label', advanced: true },
- ],
-};
+ ],};
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
diff --git a/locales/ar/common.json b/locales/ar/common.json
index b4e83bbc1..5add8df78 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "قبل النص المقتبس",
"below_quote": "بعد النص المقتبس"
},
+ "signature_new_mail": {
+ "label": "التوقيع في الرسائل الجديدة",
+ "description": "يُدرج توقيعك تلقائيًا عند كتابة رسالة جديدة. عطّله لبدء كل رسالة جديدة دون توقيع."
+ },
+ "signature_reply_forward": {
+ "label": "التوقيع في الردود وإعادة التوجيه",
+ "description": "يُرفق توقيعك تلقائيًا عند الرد على رسالة أو إعادة توجيهها. عطّله لإرسالها دون توقيع."
+ },
"signature_separator": {
"label": "فاصل التوقيع",
"description": "إضافة السطر الفاصل القياسي \"-- \" قبل التوقيع (RFC 3676). عطّله إذا كنت تفضّل الانتقال مباشرة من رسالتك إلى التوقيع."
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 0a2460822..4611e3085 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Abans del text citat",
"below_quote": "Després del text citat"
},
+ "signature_new_mail": {
+ "label": "Signatura als correus nous",
+ "description": "Insereix automàticament la teva signatura en redactar un missatge nou. Desactiva-ho per començar cada missatge nou sense ella."
+ },
+ "signature_reply_forward": {
+ "label": "Signatura en respostes i reenviaments",
+ "description": "Afegeix automàticament la teva signatura en respondre o reenviar un missatge. Desactiva-ho per enviar-los sense ella."
+ },
"signature_separator": {
"label": "Delimitador de la signatura",
"description": "Prefixa la signatura amb la línia delimitadora estàndard «-- » (RFC 3676). Desactiveu-ho si preferiu passar directament del missatge a la signatura."
diff --git a/locales/cs/common.json b/locales/cs/common.json
index cf460ede0..3bf1f764d 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "Před citovaným textem",
"below_quote": "Za citovaným textem"
},
+ "signature_new_mail": {
+ "label": "Podpis u nových zpráv",
+ "description": "Při psaní nové zprávy automaticky vloží váš podpis. Vypněte, pokud chcete každou novou zprávu začínat bez podpisu."
+ },
+ "signature_reply_forward": {
+ "label": "Podpis u odpovědí a přeposílání",
+ "description": "Při odpovídání nebo přeposílání automaticky připojí váš podpis. Vypněte, pokud je chcete odesílat bez podpisu."
+ },
"signature_separator": {
"label": "Oddělovač podpisu",
"description": "Před podpis přidat standardní oddělovací řádek \"-- \" (RFC 3676). Vypněte, pokud chcete plynule přejít z textu zprávy do podpisu."
diff --git a/locales/da/common.json b/locales/da/common.json
index 23696428d..f51a9e084 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Før citeret tekst",
"below_quote": "Efter citeret tekst"
},
+ "signature_new_mail": {
+ "label": "Signatur på nye e-mails",
+ "description": "Indsætter automatisk din signatur, når du skriver en ny besked. Slå fra for at starte hver ny besked uden."
+ },
+ "signature_reply_forward": {
+ "label": "Signatur på svar og videresendelser",
+ "description": "Tilføjer automatisk din signatur, når du svarer eller videresender. Slå fra for at sende dem uden."
+ },
"signature_separator": {
"label": "Signatur-afgrænser",
"description": "Præfiksér signaturen med standard \"-- \"-afgrænserlinjen (RFC 3676). Slå fra, hvis du hellere vil gå direkte fra din besked til signaturen."
diff --git a/locales/de/common.json b/locales/de/common.json
index f54369bf5..f563c4e36 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "Vor zitiertem Text",
"below_quote": "Nach zitiertem Text"
},
+ "signature_new_mail": {
+ "label": "Signatur bei neuen E-Mails",
+ "description": "Fügt die Signatur beim Verfassen einer neuen Nachricht automatisch ein. Ausschalten, um jede neue Nachricht ohne Signatur zu beginnen."
+ },
+ "signature_reply_forward": {
+ "label": "Signatur bei Antworten und Weiterleitungen",
+ "description": "Hängt die Signatur beim Antworten oder Weiterleiten automatisch an. Ausschalten, um sie ohne Signatur zu senden."
+ },
"signature_separator": {
"label": "Signatur-Trenner",
"description": "Der Signatur die Standard-Trennerzeile \"-- \" voranstellen (RFC 3676). Deaktivieren, wenn der Nachrichtentext direkt in die Signatur übergehen soll."
diff --git a/locales/en/common.json b/locales/en/common.json
index 2fea41def..299735960 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Before quoted text",
"below_quote": "After quoted text"
},
+ "signature_new_mail": {
+ "label": "Signature on new mail",
+ "description": "Automatically insert your signature when composing a new message. Turn off to start every new message without one."
+ },
+ "signature_reply_forward": {
+ "label": "Signature on replies & forwards",
+ "description": "Automatically append your signature when replying to or forwarding a message. Turn off to send them without one."
+ },
"signature_separator": {
"label": "Signature delimiter",
"description": "Prefix the signature with the standard \"-- \" delimiter line (RFC 3676). Turn off if you'd rather flow straight from your message into the signature."
diff --git a/locales/es/common.json b/locales/es/common.json
index 40572da71..767f69e2d 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Antes del texto citado",
"below_quote": "Después del texto citado"
},
+ "signature_new_mail": {
+ "label": "Firma en correos nuevos",
+ "description": "Inserta automáticamente tu firma al redactar un mensaje nuevo. Desactívalo para empezar cada mensaje nuevo sin ella."
+ },
+ "signature_reply_forward": {
+ "label": "Firma en respuestas y reenvíos",
+ "description": "Añade automáticamente tu firma al responder o reenviar un mensaje. Desactívalo para enviarlos sin ella."
+ },
"signature_separator": {
"label": "Delimitador de firma",
"description": "Anteponer a la firma la línea delimitadora estándar \"-- \" (RFC 3676). Desactiva si prefieres pasar directamente del mensaje a la firma."
diff --git a/locales/et/common.json b/locales/et/common.json
index bfafac30f..58d7e1450 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Enne tsiteeritud teksti",
"below_quote": "Pärast tsiteeritud teksti"
},
+ "signature_new_mail": {
+ "label": "Allkirja uutes kirjades",
+ "description": "Sisestab allkirja automaatselt uue kirja koostamisel. Lülita välja, et iga uus kiri algaks ilma allkirjata."
+ },
+ "signature_reply_forward": {
+ "label": "Allkirja vastamisel ja edastamisel",
+ "description": "Lisab allkirja automaatselt vastamisel või kirja edastamisel. Lülita välja, et saata need ilma allkirjata."
+ },
"signature_separator": {
"label": "Allkirja eraldaja",
"description": "Lisa allkirja ette standardne „-- “ eraldusrida (RFC 3676). Lülita välja, kui eelistad, et kiri sulandub otse allkirjaga."
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 7318d61d9..c81ae6376 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "قبل از نقل قول",
"below_quote": "بعد از نقل قول"
},
+ "signature_new_mail": {
+ "label": "امضا در نامههای جدید",
+ "description": "هنگام نوشتن نامه جدید، امضای شما بهطور خودکار درج میشود. برای شروع هر نامه جدید بدون امضا، خاموش کنید."
+ },
+ "signature_reply_forward": {
+ "label": "امضا در پاسخ و ارجاع",
+ "description": "هنگام پاسخ دادن یا ارجاع دادن نامه، امضای شما بهطور خودکار افزوده میشود. برای ارسال بدون امضا، خاموش کنید."
+ },
"signature_separator": {
"label": "جداکننده امضا",
"description": "پیشوند \"-- \""
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 4cbef611a..5e5216f2d 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Avant le texte cité",
"below_quote": "Après le texte cité"
},
+ "signature_new_mail": {
+ "label": "Signature sur les nouveaux messages",
+ "description": "Insère automatiquement votre signature lors de la rédaction d'un nouveau message. Désactivez pour rédiger sans signature."
+ },
+ "signature_reply_forward": {
+ "label": "Signature sur les réponses et transferts",
+ "description": "Ajoute automatiquement votre signature lors d'une réponse ou d'un transfert. Désactivez pour les envoyer sans signature."
+ },
"signature_separator": {
"label": "Délimiteur de signature",
"description": "Préfixer la signature par la ligne de délimitation standard \"-- \" (RFC 3676). Désactivez si vous préférez enchaîner directement du message à la signature."
diff --git a/locales/he/common.json b/locales/he/common.json
index a0b3debc5..2080eb871 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -1996,6 +1996,14 @@
"above_quote": "לפני טקסט מצוטט",
"below_quote": "אחרי טקסט מצוטט"
},
+ "signature_new_mail": {
+ "label": "חתימה בהודעות חדשות",
+ "description": "מוסיף אוטומטית את החתימה שלך בעת כתיבת הודעה חדשה. כבה כדי להתחיל כל הודעה חדשה בלי חתימה."
+ },
+ "signature_reply_forward": {
+ "label": "חתימה בתשובות והעברות",
+ "description": "מצרף אוטומטית את החתימה שלך בעת מענה או העברה של הודעה. כבה כדי לשלוח אותן בלי חתימה."
+ },
"signature_separator": {
"label": "מפריד חתימה",
"description": "הוסף קידומת לחתימה בעדכון סטנדרטי ״-- ״ (RFC 3676). כבה אם אתה מעדיף לזרום ישירות מההודעה שלך לחתימה."
diff --git a/locales/hu/common.json b/locales/hu/common.json
index be3994d84..d60c631bb 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Idézett szöveg előtt",
"below_quote": "Idézett szöveg után"
},
+ "signature_new_mail": {
+ "label": "Aláírás új leveleknél",
+ "description": "Új levél írása esetén automatikusan beszúrja az aláírásodat. Kapcsold ki, ha minden új levél aláírás nélkül induljon."
+ },
+ "signature_reply_forward": {
+ "label": "Aláírás válaszoknál és továbbításnál",
+ "description": "Válasz vagy továbbítás esetén automatikusan hozzáfűzi az aláírásodat. Kapcsold ki, ha ezek aláírás nélkül mennek ki."
+ },
"signature_separator": {
"label": "Aláírás elválasztó",
"description": "Aláírás előtagjaként a szabványos \"-- \" elválasztó sor (RFC 3676). Kapcsold ki, ha közvetlenül az üzenetből szeretnél az aláírásba folyni."
diff --git a/locales/it/common.json b/locales/it/common.json
index 231cc1696..7b58c4a45 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Prima del testo citato",
"below_quote": "Dopo il testo citato"
},
+ "signature_new_mail": {
+ "label": "Firma sui nuovi messaggi",
+ "description": "Inserisce automaticamente la firma quando componi un nuovo messaggio. Disattiva per iniziare ogni nuovo messaggio senza."
+ },
+ "signature_reply_forward": {
+ "label": "Firma su risposte e inoltri",
+ "description": "Aggiunge automaticamente la firma quando rispondi o inoltri un messaggio. Disattiva per inviarli senza."
+ },
"signature_separator": {
"label": "Delimitatore firma",
"description": "Anteporre alla firma la riga di delimitazione standard \"-- \" (RFC 3676). Disattiva se preferisci passare direttamente dal messaggio alla firma."
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 3571796f2..82ddb010f 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "引用テキストの前",
"below_quote": "引用テキストの後"
},
+ "signature_new_mail": {
+ "label": "新規メールの署名",
+ "description": "新しいメールを作成するときに署名を自動的に挿入します。オフにすると署名なしで作成を始められます。"
+ },
+ "signature_reply_forward": {
+ "label": "返信・転送の署名",
+ "description": "返信や転送のときに署名を自動的に追加します。オフにすると署名なしで送信されます。"
+ },
"signature_separator": {
"label": "署名区切り",
"description": "署名の前に標準の区切り行「-- 」(RFC 3676)を付けます。本文から署名へ直接続けたい場合はオフにしてください。"
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 079eac99f..b717bf9d2 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "인용 텍스트 앞",
"below_quote": "인용 텍스트 뒤"
},
+ "signature_new_mail": {
+ "label": "새 메일 서명",
+ "description": "새 메일을 작성할 때 서명을 자동으로 삽입합니다. 끄면 서명 없이 새 메일을 시작합니다."
+ },
+ "signature_reply_forward": {
+ "label": "답장·전달 서명",
+ "description": "답장이나 전달할 때 서명을 자동으로 추가합니다. 끄면 서명 없이 보냅니다."
+ },
"signature_separator": {
"label": "서명 구분선",
"description": "서명 앞에 표준 구분선 \"-- \" (RFC 3676)을 추가합니다. 본문에서 바로 서명으로 이어지길 원하면 해제하세요."
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 64eeef5f5..9cc7121ae 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Pirms citētā teksta",
"below_quote": "Pēc citētā teksta"
},
+ "signature_new_mail": {
+ "label": "Paraksts jaunajos e-pastos",
+ "description": "Rakstot jaunu vēstuli, automātiski ievieto jūsu parakstu. Izslēdziet, lai katra jauna vēstule sāktos bez tā."
+ },
+ "signature_reply_forward": {
+ "label": "Paraksts atbildēs un pārsūtījumos",
+ "description": "Atbildot vai pārsūtot vēstuli, automātiski pievieno jūsu parakstu. Izslēdziet, lai tās tiktu nosūtītas bez tā."
+ },
"signature_separator": {
"label": "Paraksta atdalītājs",
"description": "Pirms paraksta pievienot standarta atdalītāju \"-- \" (RFC 3676). Izslēdziet, ja vēlaties pāriet no ziņojuma tieši uz parakstu."
diff --git a/locales/mn/common.json b/locales/mn/common.json
index e09096e2f..4fc940fde 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Ишлэлээс өмнө",
"below_quote": "Иш иш татсаны дараа"
},
+ "signature_new_mail": {
+ "label": "Шинэ захидалд гарын үсэг",
+ "description": "Шинэ захидал бичих үед гарын үсгээ автоматаар оруулна. Шинэ захидал бүрийг гарын үсэггүй эхлүүлэх бол унтраана уу."
+ },
+ "signature_reply_forward": {
+ "label": "Хариу, дамжуулахад гарын үсэг",
+ "description": "Захидалд хариу өгөх эсвэл дамжуулах үед гарын үсгээ автоматаар хавсаргана. Гарын үсэггүй илгээхийн тулд унтраана уу."
+ },
"signature_separator": {
"label": "Гарын үсэг хязгаарлагч",
"description": "Гарын үсгийн угтварыг стандарт \"--\" хязгаарлагч шугамаар (RFC 3676) оруулна. Хэрэв та мессежээсээ шууд гарын үсэг рүү шилжихийг хүсвэл унтраа."
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6218c62c6..b193db576 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Før sitert tekst",
"below_quote": "Etter sitert tekst"
},
+ "signature_new_mail": {
+ "label": "Signatur på nye e-poster",
+ "description": "Setter automatisk inn signaturen din når du skriver en ny melding. Slå av for å starte hver ny melding uten."
+ },
+ "signature_reply_forward": {
+ "label": "Signatur på svar og videresendinger",
+ "description": "Legger automatisk til signaturen din når du svarer eller videresender. Slå av for å sende dem uten."
+ },
"signature_separator": {
"label": "Signaturavgrensning",
"description": "Sett standardavgrensningen «-- » på en egen linje foran signaturen (RFC 3676). Slå av hvis meldingen skal gå rett over i signaturen."
diff --git a/locales/nl/common.json b/locales/nl/common.json
index d43ba15c7..b8e39f188 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Voor geciteerde tekst",
"below_quote": "Na geciteerde tekst"
},
+ "signature_new_mail": {
+ "label": "Handtekening bij nieuwe berichten",
+ "description": "Voegt je handtekening automatisch toe bij het opstellen van een nieuw bericht. Zet uit om elk nieuw bericht zonder te beginnen."
+ },
+ "signature_reply_forward": {
+ "label": "Handtekening bij antwoorden en doorsturen",
+ "description": "Voegt je handtekening automatisch toe bij antwoorden of doorsturen. Zet uit om ze zonder te versturen."
+ },
"signature_separator": {
"label": "Handtekening-scheider",
"description": "De handtekening voorafgaan met de standaard scheidingsregel \"-- \" (RFC 3676). Zet uit als je liever direct van het bericht in de handtekening overgaat."
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 113232449..a00859682 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "Przed cytowanym tekstem",
"below_quote": "Po cytowanym tekście"
},
+ "signature_new_mail": {
+ "label": "Podpis w nowych wiadomościach",
+ "description": "Automatycznie wstawia podpis podczas pisania nowej wiadomości. Wyłącz, aby zaczynać każdą nową wiadomość bez niego."
+ },
+ "signature_reply_forward": {
+ "label": "Podpis w odpowiedziach i przesyłanych",
+ "description": "Automatycznie dołącza podpis przy odpowiadaniu lub przesyłaniu dalej. Wyłącz, aby wysyłać je bez niego."
+ },
"signature_separator": {
"label": "Separator podpisu",
"description": "Poprzedź podpis standardową linią separatora \"-- \" (RFC 3676). Wyłącz, jeśli chcesz przejść bezpośrednio z treści wiadomości do podpisu."
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a1bb94a19..7e164b26a 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Antes do texto citado",
"below_quote": "Depois do texto citado"
},
+ "signature_new_mail": {
+ "label": "Assinatura em novas mensagens",
+ "description": "Insere automaticamente a sua assinatura ao escrever uma nova mensagem. Desative para começar cada nova mensagem sem ela."
+ },
+ "signature_reply_forward": {
+ "label": "Assinatura em respostas e encaminhamentos",
+ "description": "Acrescenta automaticamente a sua assinatura ao responder ou encaminhar uma mensagem. Desative para as enviar sem ela."
+ },
"signature_separator": {
"label": "Delimitador de assinatura",
"description": "Anteceder a assinatura com a linha delimitadora padrão \"-- \" (RFC 3676). Desative se preferir passar direto da mensagem para a assinatura."
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 326779d43..feaf6fd0d 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Înaintea textului citat",
"below_quote": "După textul între ghilimele"
},
+ "signature_new_mail": {
+ "label": "Semnătură la mesajele noi",
+ "description": "Inserează automat semnătura când compui un mesaj nou. Dezactivează pentru a începe fiecare mesaj nou fără ea."
+ },
+ "signature_reply_forward": {
+ "label": "Semnătură la răspunsuri și redirecționări",
+ "description": "Adaugă automat semnătura când răspunzi sau redirecționezi un mesaj. Dezactivează pentru a le trimite fără ea."
+ },
"signature_separator": {
"label": "Delimitator de semnătură",
"description": "Prefațați semnătura cu linia standard de delimitare „-- ” (RFC 3676). Dezactivați această opțiune dacă preferați ca mesajul să treacă direct în semnătură."
diff --git a/locales/ru/common.json b/locales/ru/common.json
index c6d3f6bce..751f4992f 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -1940,6 +1940,14 @@
"above_quote": "Перед цитируемым текстом",
"below_quote": "После цитируемого текста"
},
+ "signature_new_mail": {
+ "label": "Подпись в новых письмах",
+ "description": "Автоматически вставляет подпись при написании нового письма. Отключите, чтобы начинать каждое новое письмо без неё."
+ },
+ "signature_reply_forward": {
+ "label": "Подпись в ответах и пересылках",
+ "description": "Автоматически добавляет подпись при ответе или пересылке письма. Отключите, чтобы отправлять их без неё."
+ },
"signature_separator": {
"label": "Разделитель подписи",
"description": "Добавлять перед подписью стандартную строку-разделитель \"-- \" (RFC 3676). Отключите, если хотите переходить от текста сразу к подписи."
diff --git a/locales/sk/common.json b/locales/sk/common.json
index bb5bb9044..be6e481b0 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "Pred citovaným textom",
"below_quote": "Za citovaným textom"
},
+ "signature_new_mail": {
+ "label": "Podpis pri nových správach",
+ "description": "Pri písaní novej správy automaticky vloží váš podpis. Vypnite, ak chcete každú novú správu začať bez podpisu."
+ },
+ "signature_reply_forward": {
+ "label": "Podpis pri odpovediach a preposielaní",
+ "description": "Pri odpovedaní alebo preposielaní automaticky priloží váš podpis. Vypnite, ak ich chcete odosielať bez podpisu."
+ },
"signature_separator": {
"label": "Oddeľovač podpisu",
"description": "Pred podpis pridať štandardný oddeľovací riadok \"-- \" (RFC 3676)."
diff --git a/locales/th/common.json b/locales/th/common.json
index e2a6bc31a..8ed2a5aaa 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -4621,6 +4621,14 @@
"above_quote": "ก่อนข้อความที่อ้างอิง",
"below_quote": "หลังข้อความที่อ้างอิง"
},
+ "signature_new_mail": {
+ "label": "ลายเซ็นในอีเมลใหม่",
+ "description": "แทรกลายเซ็นของคุณโดยอัตโนมัติเมื่อเขียนอีเมลใหม่ ปิดใช้งานหากต้องการเริ่มอีเมลใหม่ทุกฉบับโดยไม่มีลายเซ็น"
+ },
+ "signature_reply_forward": {
+ "label": "ลายเซ็นในการตอบกลับและส่งต่อ",
+ "description": "แนบลายเซ็นของคุณโดยอัตโนมัติเมื่อตอบกลับหรือส่งต่ออีเมล ปิดใช้งานหากต้องการส่งโดยไม่มีลายเซ็น"
+ },
"signature_separator": {
"label": "เส้นคั่นลายเซ็น",
"description": "ใส่เส้นคั่นมาตรฐาน \"-- \" นำหน้าลายเซ็น (RFC 3676) ปิดใช้งานหากคุณต้องการให้ข้อความของคุณไหลต่อเนื่องเข้าสู่ลายเซ็นทันที"
diff --git a/locales/tr/common.json b/locales/tr/common.json
index d0b004a0b..10fbee958 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "Alıntılanan metinden önce",
"below_quote": "Alıntılanan metinden sonra"
},
+ "signature_new_mail": {
+ "label": "Yeni e-postalarda imza",
+ "description": "Yeni bir ileti yazarken imzanızı otomatik olarak ekler. Her yeni iletiye imzasız başlamak için kapatın."
+ },
+ "signature_reply_forward": {
+ "label": "Yanıtlarda ve iletmelerde imza",
+ "description": "Bir iletiyi yanıtlarken veya iletirken imzanızı otomatik olarak ekler. İmzasız göndermek için kapatın."
+ },
"signature_separator": {
"label": "İmza ayırıcı",
"description": "İmzayı standart \"-- \" ayırıcı satırıyla başlat (RFC 3676). Mesajdan doğrudan imzaya geçmek istiyorsanız kapatın."
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 794ad0b85..871528a67 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "Перед цитованим текстом",
"below_quote": "Після цитованого тексту"
},
+ "signature_new_mail": {
+ "label": "Підпис у нових листах",
+ "description": "Автоматично вставляє підпис під час написання нового листа. Вимкніть, щоб кожен новий лист починався без нього."
+ },
+ "signature_reply_forward": {
+ "label": "Підпис у відповідях і пересиланнях",
+ "description": "Автоматично додає підпис під час відповіді або пересилання листа. Вимкніть, щоб надсилати їх без нього."
+ },
"signature_separator": {
"label": "Розділювач підпису",
"description": "Додавати перед підписом стандартний рядок-розділювач \"-- \" (RFC 3676). Вимкніть, якщо хочете переходити з повідомлення відразу до підпису."
diff --git a/locales/vi/common.json b/locales/vi/common.json
index b12aa9c13..23815f076 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -1964,6 +1964,14 @@
"above_quote": "Trước phần trích dẫn",
"below_quote": "Sau phần trích dẫn"
},
+ "signature_new_mail": {
+ "label": "Chữ ký trong thư mới",
+ "description": "Tự động chèn chữ ký của bạn khi soạn thư mới. Tắt nếu bạn muốn mỗi thư mới bắt đầu không có chữ ký."
+ },
+ "signature_reply_forward": {
+ "label": "Chữ ký khi trả lời và chuyển tiếp",
+ "description": "Tự động thêm chữ ký của bạn khi trả lời hoặc chuyển tiếp thư. Tắt nếu muốn gửi chúng không kèm chữ ký."
+ },
"signature_separator": {
"label": "Dấu phân cách chữ ký",
"description": "Thêm dòng phân cách chuẩn \"-- \" trước chữ ký (RFC 3676). Tắt nếu bạn muốn nối thẳng nội dung thư với chữ ký."
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index e4b2c38be..3c4e48fc3 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -1952,6 +1952,14 @@
"above_quote": "引用文字之前",
"below_quote": "引用文字之後"
},
+ "signature_new_mail": {
+ "label": "新郵件簽名",
+ "description": "撰寫新郵件時自動插入簽名。關閉後每封新郵件都不帶簽名。"
+ },
+ "signature_reply_forward": {
+ "label": "回覆轉寄簽名",
+ "description": "回覆或轉寄時自動附加簽名。關閉後回覆和轉寄不帶簽名。"
+ },
"signature_separator": {
"label": "簽名檔分隔線",
"description": "在簽名檔前加入標準的「-- 」分隔線(RFC 3676)。若希望內文直接接續簽名檔,請停用此選項。"
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 012aa038d..10eb2c6a1 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -1949,6 +1949,14 @@
"above_quote": "引用文本之前",
"below_quote": "引用文本之后"
},
+ "signature_new_mail": {
+ "label": "新邮件签名",
+ "description": "写新邮件时自动插入签名。关闭后每封新邮件都不带签名。"
+ },
+ "signature_reply_forward": {
+ "label": "回复转发签名",
+ "description": "回复或转发时自动附加签名。关闭后回复和转发不带签名。"
+ },
"signature_separator": {
"label": "签名分隔符",
"description": "在签名前加入标准的 “-- ” 分隔行(RFC 3676)。如果希望正文直接连到签名,请关闭。"
diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts
index d8ba0ae96..0a0ad153a 100644
--- a/stores/__tests__/settings-store-sync.test.ts
+++ b/stores/__tests__/settings-store-sync.test.ts
@@ -52,6 +52,20 @@ describe('settings sync debounce', () => {
});
});
+ it('defaults the signature auto-insert toggles and the separator to off, and exports all three', () => {
+ const settings = useSettingsStore.getState();
+ expect(settings.signatureNewMailEnabled).toBe(false);
+ expect(settings.signatureReplyForwardEnabled).toBe(false);
+ expect(settings.signatureSeparatorEnabled).toBe(false);
+
+ const exported = JSON.parse(settings.exportSettings()) as Record;
+ expect(exported).toMatchObject({
+ signatureNewMailEnabled: false,
+ signatureReplyForwardEnabled: false,
+ signatureSeparatorEnabled: false,
+ });
+ });
+
it('does not rewrite a pending account snapshot after another account becomes active', async () => {
const settings = useSettingsStore.getState();
settings.enableSync('a@example.com', 'https://mail-a.example.com');
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 2427a5965..56b69fa93 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -590,6 +590,8 @@ interface SettingsState extends AccessibilitySettings {
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
sendDelaySeconds: SendDelaySeconds;
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
+ signatureNewMailEnabled: boolean; // Insert the signature automatically in new messages
+ signatureReplyForwardEnabled: boolean; // Insert the signature automatically in replies and forwards
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
signatureDetails: SignatureDetails; // Title, department, phone, mobile for the organisation signature (lib/org-signature.ts)
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
@@ -930,7 +932,9 @@ const DEFAULT_SETTINGS = {
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition,
- signatureSeparatorEnabled: true,
+ signatureNewMailEnabled: false,
+ signatureReplyForwardEnabled: false,
+ signatureSeparatorEnabled: false,
signatureDetails: EMPTY_SIGNATURE_DETAILS,
requestReadReceiptDefault: false,
readReceiptResponse: 'ask' as ReadReceiptResponse,
@@ -1364,6 +1368,8 @@ export const useSettingsStore = create()(
subAddressDelimiter: state.subAddressDelimiter,
sendDelaySeconds: state.sendDelaySeconds,
signaturePosition: state.signaturePosition,
+ signatureNewMailEnabled: state.signatureNewMailEnabled,
+ signatureReplyForwardEnabled: state.signatureReplyForwardEnabled,
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
signatureDetails: state.signatureDetails,
requestReadReceiptDefault: state.requestReadReceiptDefault,
From 62906e45dcc69779d9c7e79c08a7bef777dfc576 Mon Sep 17 00:00:00 2001
From: Szymon Chroboczek
Date: Mon, 2 Nov 2026 12:47:44 +0100
Subject: [PATCH 08/21] fix: preserve mailbox owner in push notification links
---
.../mail-app-source-routing.test.tsx | 26 +++++++
components/mail/mail-app.tsx | 6 +-
integration/tests/20-push-owner.spec.ts | 75 +++++++++++++++++++
lib/__tests__/helpers/push-click.ts | 57 ++++++++++++++
lib/__tests__/sw-push-owner.test.ts | 57 ++++++++++++++
lib/deep-links.ts | 15 +++-
public/sw.js | 17 +++--
7 files changed, 241 insertions(+), 12 deletions(-)
create mode 100644 integration/tests/20-push-owner.spec.ts
create mode 100644 lib/__tests__/helpers/push-click.ts
create mode 100644 lib/__tests__/sw-push-owner.test.ts
diff --git a/components/mail/__tests__/mail-app-source-routing.test.tsx b/components/mail/__tests__/mail-app-source-routing.test.tsx
index b0dd78261..329c8c0a1 100644
--- a/components/mail/__tests__/mail-app-source-routing.test.tsx
+++ b/components/mail/__tests__/mail-app-source-routing.test.tsx
@@ -256,3 +256,29 @@ describe('archiving a group message by month', () => {
expect(client.createMailbox).toHaveBeenCalledWith('2026', 'k', 'ce', undefined);
});
});
+
+describe('shared message deep link from the personal Inbox (#1491)', () => {
+ it('selects the shared folder for a freshly fetched message without source stamps', async () => {
+ window.history.replaceState(null, '', '/mail/message/eaaaaab?owner=ce');
+ const email = { ...groupMessage, mailboxIds: { 'ce:a': true }, sourceClientAccountId: undefined, sourceAccountId: undefined } as Email;
+ const fetchContent = vi.fn().mockImplementation(async () => {
+ useEmailStore.setState({ selectedEmail: email });
+ return email;
+ });
+ const fetchEmails = vi.fn().mockResolvedValue(undefined);
+ useEmailStore.setState({
+ emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null,
+ fetchEmailContent: fetchContent, fetchEmails,
+ });
+ const mounted = render();
+ try {
+ await waitFor(() => expect(fetchContent).toHaveBeenCalledWith(client, 'eaaaaab', 'ce'));
+ await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
+ expect(fetchEmails).toHaveBeenCalledWith(client, 'ce:a', { background: true });
+ expect(useEmailStore.getState().selectedEmail?.subject).toBe('GRP one');
+ } finally {
+ mounted.unmount();
+ window.history.replaceState(null, '', '/');
+ }
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 33ae06070..e40a46794 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1796,7 +1796,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? await freshMailboxes(switchedFrom)
: useEmailStore.getState().mailboxes;
const state = useEmailStore.getState();
- const mailboxId = resolveFolderRef(link.ref, mailboxes);
+ const mailboxId = resolveFolderRef(
+ link.ref,
+ mailboxes,
+ link.ownerAccountId === activeClient.getAccountId() ? undefined : link.ownerAccountId,
+ );
if (!mailboxId) {
toast.error(t('deep_link.folder_not_found'));
return;
diff --git a/integration/tests/20-push-owner.spec.ts b/integration/tests/20-push-owner.spec.ts
new file mode 100644
index 000000000..b51725917
--- /dev/null
+++ b/integration/tests/20-push-owner.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from '@playwright/test';
+import { ACCOUNTS, GROUP, WEBMAIL_URL } from './helpers/config';
+import { JmapClient } from './helpers/jmap';
+import { login, seedSettings, folderRow, expandSharedFolders, emailItem, addAccount, switchAccount } from './helpers/app';
+import { pushClick } from '../../lib/__tests__/helpers/push-click';
+
+test.describe('notification target across personal and shared accounts (#1491)', () => {
+ const cases = [false, true].flatMap(shared => [1, 3].flatMap(unread =>
+ [false, true].map(warm => ({ shared, unread, warm, secondary: false })),
+ ));
+ cases.push(...[1, 3].map(unread => ({ shared: true, unread, warm: true, secondary: true })));
+ for (const { shared, unread, warm, secondary } of cases) {
+ test(`${shared ? 'shared' : 'personal'}, ${unread} unread, ${warm ? 'existing' : 'new'} window${secondary ? ', second login' : ''}`, async ({ page }) => {
+ const member = ACCOUNTS.carol;
+ const client = await JmapClient.connect(member.email, member.password);
+ const sharedId = client.accountIdByName(GROUP.team.email);
+ await client.reset();
+ await client.reset(sharedId);
+ const targetId = shared ? sharedId : client.accountId;
+ let deliveredId = '';
+ const subject = `Push target ${shared ? 'shared' : 'personal'} ${unread}`;
+ for (const owner of [client.accountId, sharedId]) {
+ const inbox = (await client.mailboxByRole('inbox', owner))!;
+ for (let i = 0; i < unread; i++) {
+ const result = await client.request([['Email/set', {
+ accountId: owner,
+ create: { mail: {
+ mailboxIds: { [inbox.id]: true }, keywords: {},
+ from: [{ email: ACCOUNTS.alice.email }],
+ to: [{ email: owner === sharedId ? GROUP.team.email : member.email }],
+ subject: owner === targetId ? `${subject} ${i}` : `Other mailbox ${i}`,
+ receivedAt: new Date(Date.now() + i * 1000).toISOString(),
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ bodyValues: { b: { value: `Body in ${owner}` } },
+ } },
+ }, '0']]);
+ expect(result.methodResponses[0][1].created?.mail).toBeTruthy();
+ if (owner === targetId) deliveredId = result.methodResponses[0][1].created.mail.id;
+ }
+ }
+ await seedSettings(page, { enableUnifiedMailbox: false, mailLayout: 'split' });
+ if (secondary) {
+ await login(page, ACCOUNTS.bob);
+ await addAccount(page, member);
+ await switchAccount(page, ACCOUNTS.bob.email);
+ } else {
+ await login(page, member);
+ }
+ await expect(folderRow(page, { role: 'inbox', shared: false }).first()).toHaveAttribute('data-selected', 'true');
+ const target = await pushClick(targetId, async (url) => {
+ const response = await page.request.get(new URL(url, WEBMAIL_URL).href);
+ expect(response.status()).toBe(200);
+ const preview = await response.json();
+ expect(preview.unreadTotal).toBe(unread);
+ expect(preview.slot).toBe(secondary ? 1 : 0);
+ expect(preview.email.subject).toBe(`${subject} ${unread - 1}`);
+ return preview;
+ }, { warm, emailId: deliveredId });
+ if (!warm) {
+ const nextPage = await page.context().newPage();
+ await page.close();
+ page = nextPage;
+ }
+ await page.goto(target);
+ if (unread === 1) {
+ await expect(page.locator('[data-tour="email-viewer"]')).toContainText(`${subject} 0`);
+ }
+ if (shared) await expandSharedFolders(page, GROUP.team.email);
+ await expect(folderRow(page, { role: 'inbox', shared }).first()).toHaveAttribute('data-selected', 'true');
+ if (unread > 1) {
+ await expect(emailItem(page, `${subject} ${unread - 1}`).first()).toBeVisible();
+ }
+ });
+ }
+});
diff --git a/lib/__tests__/helpers/push-click.ts b/lib/__tests__/helpers/push-click.ts
new file mode 100644
index 000000000..7509a9c73
--- /dev/null
+++ b/lib/__tests__/helpers/push-click.ts
@@ -0,0 +1,57 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+
+export async function pushClick(
+ accountId: string,
+ preview: (url: string) => Promise,
+ options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string } = {},
+): Promise {
+ const source = readFileSync(resolve('public/sw.js'), 'utf8');
+ let notification: { data: unknown } | undefined;
+ let target: string | undefined;
+ const listeners = new Map void>();
+ const cache = new Map();
+ const client = {
+ id: 'window-1', url: 'https://mail.example.test/',
+ postMessage() {},
+ async focus() { return this; },
+ async navigate(url: string) { target = url; return this; },
+ };
+ const context = vm.createContext({
+ URL, URLSearchParams, Response, AbortController, setTimeout, clearTimeout, console,
+ self: {
+ location: { href: `https://mail.example.test${options.basePath ?? ''}/sw.js`, origin: 'https://mail.example.test' },
+ addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
+ clients: {
+ matchAll: async () => options.warm ? [client] : [],
+ openWindow: async (url: string) => { target = url; },
+ },
+ registration: {
+ showNotification: async (_title: string, data: { data: unknown }) => { notification = data; },
+ getNotifications: async () => [],
+ },
+ },
+ caches: { open: async () => ({
+ match: async (key: string) => cache.has(key) ? new Response(cache.get(key)) : undefined,
+ put: async (key: string, response: Response) => cache.set(key, await response.text()),
+ }) },
+ fetch: async (url: string) => ({ ok: true, json: async () => preview(url) }),
+ });
+ vm.runInContext(source, context);
+ async function dispatch(type: string, event: Record) {
+ const waits: Promise[] = [];
+ const listener = listeners.get(type);
+ if (!listener) throw new Error(`Missing worker handler: ${type}`);
+ listener({ ...event, waitUntil: (p: Promise) => waits.push(p) });
+ await Promise.all(waits);
+ }
+ const payload = options.legacy
+ ? { changed: { [accountId]: { EmailDelivery: 'new' } } }
+ : { kind: 'jmap-email-push', accountId, emailIds: [options.emailId ?? 'new-email'] };
+ await dispatch('push', { data: { json: () => payload } });
+ if (!notification) throw new Error('No notification shown');
+ await dispatch('notificationclick', { notification: { ...notification, close() {} } });
+ if (!target) throw new Error('Notification did not navigate');
+ return target;
+}
diff --git a/lib/__tests__/sw-push-owner.test.ts b/lib/__tests__/sw-push-owner.test.ts
new file mode 100644
index 000000000..ace7cf58d
--- /dev/null
+++ b/lib/__tests__/sw-push-owner.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+import { parseMailPath, resolveFolderRef } from '@/lib/deep-links';
+import type { Mailbox } from '@/lib/jmap/types';
+import { pushClick } from './helpers/push-click';
+
+const folders = [
+ { id: 'a', name: 'Inbox', role: 'inbox', accountId: 'personal', totalEmails: 0, unreadEmails: 0 },
+ { id: 'team:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'team', totalEmails: 0, unreadEmails: 0 },
+] as Mailbox[];
+
+function parseTarget(target: string) {
+ const url = new URL(target, 'https://mail.example.test');
+ return parseMailPath(url.pathname.split('/mail/')[1].split('/'), url.searchParams);
+}
+
+describe('push click keeps the owning mailbox (#1491)', () => {
+ for (const owner of ['personal', 'team']) {
+ for (const unreadTotal of [1, 3]) {
+ for (const warm of [false, true]) {
+ it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => {
+ const target = await pushClick(owner, async (url) => {
+ expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner);
+ return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
+ }, { warm });
+ const link = parseTarget(target);
+ expect(link?.slot).toBe(0);
+ if (unreadTotal === 1) {
+ expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner });
+ } else {
+ expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner });
+ if (link?.kind !== 'folder') throw new Error('Expected folder link');
+ expect(resolveFolderRef(link.ref, folders, link.ownerAccountId)).toBe(owner === 'team' ? 'team:a' : 'a');
+ }
+ });
+ }
+ }
+ }
+
+ it('keeps the owner when organisation policy hides message details', async () => {
+ const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 1, redacted: true, email: { id: 'same-id' } }));
+ expect(parseTarget(target)).toMatchObject({ kind: 'message', ownerAccountId: 'team', slot: 2 });
+ });
+
+ it('keeps the login slot and owner with a mount prefix and legacy payload', async () => {
+ const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true });
+ expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/);
+ expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 });
+ });
+
+ it('does not fall back to a personal folder for an unavailable owner', () => {
+ expect(resolveFolderRef('inbox', folders, 'unavailable')).toBeNull();
+ });
+
+ it('keeps old unscoped folder links working', () => {
+ expect(resolveFolderRef('inbox', folders)).toBe('a');
+ });
+});
diff --git a/lib/deep-links.ts b/lib/deep-links.ts
index e3c1459a4..483ad33a9 100644
--- a/lib/deep-links.ts
+++ b/lib/deep-links.ts
@@ -136,7 +136,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j
* login by its cookie slot instead - what a push notification knows.
*/
export type MailDeepLink =
- | { kind: 'folder'; ref: string; accountId?: string; slot?: number }
+ | { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string }
| { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string }
| { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string };
@@ -179,9 +179,18 @@ export function buildFolderRef(mailboxId: string, mailboxes: Mailbox[] = []): st
* Exact ids are checked before aliases so a custom folder whose id happens to
* read like an alias still resolves to itself.
*/
-export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = []): string | null {
+export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = [], ownerAccountId?: string): string | null {
const decoded = decodeSegment(ref);
+ if (ownerAccountId) {
+ const owned = mailboxes.filter((mailbox) => mailbox.accountId === ownerAccountId);
+ const exact = owned.find((mailbox) => mailbox.id === decoded);
+ if (exact) return exact.id;
+ return ALIASED_ROLES.has(decoded)
+ ? owned.find((mailbox) => mailbox.role === decoded)?.id ?? null
+ : null;
+ }
+
if (mailboxes.some((m) => m.id === decoded)) return decoded;
const virtual = VIRTUAL_FOLDER_ALIASES[decoded];
@@ -284,7 +293,7 @@ export function parseMailPath(
if (id) {
if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId };
if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId };
- if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot };
+ if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId };
}
}
diff --git a/public/sw.js b/public/sw.js
index a33068c02..975db0c27 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -521,7 +521,7 @@ async function handlePush(event) {
// (Organisation > Devices): the route sent neither.
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
- if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, slot };
+ if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot };
} else if (email) {
const sender = email.from && email.from[0];
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
@@ -534,7 +534,7 @@ async function handlePush(event) {
body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`);
} else {
// Exactly one unread: deep-link straight to that message on click.
- data = { kind: "email", emailId: email.id, threadId: email.threadId, slot };
+ data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot };
}
} else {
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
@@ -922,16 +922,17 @@ function getReusableClientScore(state) {
function buildClickUrl(data) {
if (!data) return `${BASE_PATH}/`;
- // `?slot=` names the login the message belongs to (see the preview API).
- const slotQuery = Number.isInteger(data.slot) ? `?slot=${data.slot}` : "";
+ const params = new URLSearchParams();
+ if (Number.isInteger(data.slot)) params.set("slot", String(data.slot));
+ if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId);
+ const targetQuery = params.size ? `?${params}` : "";
if (data.kind === "email" && data.emailId) {
// Permalink (#733). Under NEXT_PUBLIC_LOCALE_PREFIX=always the proxy
// redirects this to the localised path; the worker has no locale to add.
- return `${BASE_PATH}/mail/message/${encodeURIComponent(data.emailId)}${slotQuery}`;
+ return `${BASE_PATH}/mail/message/${encodeURIComponent(data.emailId)}${targetQuery}`;
}
- // A group of new mail in a known login: that login's Inbox.
- if (data.kind === "mail-list" && slotQuery) {
- return `${BASE_PATH}/mail/folder/inbox${slotQuery}`;
+ if (data.kind === "mail-list" && Number.isInteger(data.slot)) {
+ return `${BASE_PATH}/mail/folder/inbox${targetQuery}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the
From 2262b2baf15a6c5aa68cbe7a02386b4abb8d5ea6 Mon Sep 17 00:00:00 2001
From: Nick Sweeting
Date: Mon, 2 Nov 2026 09:06:27 -0800
Subject: [PATCH 09/21] fix: paginate mailbox retrieval without sidebar changes
---
lib/__tests__/jmap-mailbox-pagination.test.ts | 157 ++++++++++++++++++
lib/jmap/client.ts | 146 +++++++++++-----
lib/offline/__tests__/client-fallback.test.ts | 94 ++++++++++-
lib/offline/responder.ts | 20 ++-
4 files changed, 378 insertions(+), 39 deletions(-)
create mode 100644 lib/__tests__/jmap-mailbox-pagination.test.ts
diff --git a/lib/__tests__/jmap-mailbox-pagination.test.ts b/lib/__tests__/jmap-mailbox-pagination.test.ts
new file mode 100644
index 000000000..461a2bee6
--- /dev/null
+++ b/lib/__tests__/jmap-mailbox-pagination.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+type RawMailbox = { id: string; name: string; parentId?: string };
+type MethodCall = [string, Record, string];
+type RequestTarget = { request(calls: MethodCall[]): Promise };
+
+function createClient(
+ maxObjectsInGet = 2,
+ accounts: Record = { primary: { name: 'user@example.com' } },
+): JMAPClient {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, {
+ apiUrl: 'https://jmap.example.com/api',
+ accountId: 'primary',
+ username: 'user@example.com',
+ accounts,
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet } },
+ });
+ return client;
+}
+
+function serve(client: JMAPClient, mailboxes: Record, clamp = Infinity, withTotal = true): MethodCall[][] {
+ const requests: MethodCall[][] = [];
+ vi.spyOn(client as unknown as RequestTarget, 'request').mockImplementation(async (calls) => {
+ if (calls[0][0] === 'Mailbox/get') {
+ return { methodResponses: calls.map(([, args, tag]) => [
+ 'Mailbox/get', { list: (mailboxes[args.accountId as string] ?? []).slice(0, client.getMaxObjectsInGet()), state: 'm' }, tag,
+ ]) };
+ }
+ requests.push(calls);
+ const accountId = calls[0][1].accountId as string;
+ const position = calls[0][1].position as number;
+ const limit = calls[0][1].limit as number;
+ const all = mailboxes[accountId] ?? [];
+ const page = all.slice(position, position + Math.min(limit, clamp));
+ return { methodResponses: [
+ ['Mailbox/query', { ids: page.map(({ id }) => id), queryState: 's', ...(withTotal && calls[0][1].calculateTotal ? { total: all.length } : {}) }, 'query'],
+ ['Mailbox/get', { list: page, state: 'm', notFound: [] }, 'get'],
+ ] };
+ });
+ return requests;
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('JMAP mailbox pagination', () => {
+ it('loads a complete hierarchy across server-clamped pages', async () => {
+ const client = createClient(2);
+ const requests = serve(client, { primary: [
+ { id: 'grandchild', name: 'Grandchild', parentId: 'child' },
+ { id: 'other', name: 'Other' },
+ { id: 'child', name: 'Child', parentId: 'root' },
+ { id: 'root', name: 'Root' },
+ ] }, 1);
+
+ const mailboxes = await client.getMailboxes();
+
+ expect(mailboxes.map(({ id, parentId }) => [id, parentId])).toEqual([
+ ['grandchild', 'child'], ['other', undefined], ['child', 'root'], ['root', undefined],
+ ]);
+ expect(requests.map(([query]) => query[1].position)).toEqual([0, 1, 2, 3]);
+ expect(requests.map(([query]) => query[1].calculateTotal)).toEqual([true, false, false, false]);
+ expect(requests.every((calls) => calls.length === 2 && calls[1][1]['#ids'] && !calls[1][1].ids)).toBe(true);
+ });
+
+ it.each([
+ ['missing ids', undefined, 's', []],
+ ['mixed ids', ['third', 4], 's', []],
+ ['duplicate ids', ['parent'], 's', [{ id: 'parent', name: 'Parent' }]],
+ ['changed state', ['third'], 's2', [{ id: 'third', name: 'Third' }]],
+ ['early empty page', [], 's', []],
+ ['missing object', ['third'], 's', []],
+ ])('rejects %s instead of publishing a partial hierarchy', async (_label, ids, queryState, list) => {
+ const client = createClient();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'child' }, { id: 'parent' }] }, '0'],
+ ] })
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/query', { ids: ['child', 'parent'], queryState: 's', total: 3 }, 'query'],
+ ['Mailbox/get', { list: [{ id: 'child', name: 'Child' }, { id: 'parent', name: 'Parent' }] }, 'get'],
+ ] })
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/query', { ids, queryState }, 'query'],
+ ['Mailbox/get', { list }, 'get'],
+ ] });
+
+ await expect(client.getMailboxes()).rejects.toThrow();
+ });
+
+ it('prefixes shared mailbox and parent ids', async () => {
+ const client = createClient(1, { primary: { name: 'user@example.com' }, shared: { name: 'team@example.com' } });
+ serve(client, {
+ primary: [{ id: 'inbox', name: 'Inbox' }],
+ shared: [{ id: 'child', name: 'Child', parentId: 'parent' }, { id: 'parent', name: 'Parent' }],
+ });
+
+ expect((await client.getAllMailboxes()).find(({ name }) => name === 'Child')).toMatchObject({
+ id: 'shared:child', originalId: 'child', parentId: 'shared:parent', accountId: 'shared', isShared: true,
+ });
+ });
+
+ it('keeps small accounts on the existing single-request fast path', async () => {
+ const client = createClient(3);
+ const requests = serve(client, { primary: [{ id: 'inbox', name: 'Inbox' }] });
+ expect(await client.getMailboxes()).toHaveLength(1);
+ expect(requests).toHaveLength(0);
+ expect(client['request']).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([true, false])('loads an exact page multiple (total returned: %s)', async (withTotal) => {
+ const client = createClient(2);
+ const list = Array.from({ length: 4 }, (_, i) => ({ id: String(i), name: String(i) }));
+ const requests = serve(client, { primary: list }, Infinity, withTotal);
+ expect(await client.getMailboxes()).toHaveLength(4);
+ expect(requests.map(([query]) => query[1].position)).toEqual(withTotal ? [0, 2] : [0, 2, 4]);
+ expect(requests.map(([query]) => query[1].calculateTotal)).toEqual(withTotal ? [true, false] : [true, false, false]);
+ });
+
+ it('retains Mailbox collection state and packed small-account retrieval', async () => {
+ const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
+ serve(client, {
+ primary: [{ id: 'inbox', name: 'Inbox' }],
+ shared: [{ id: 'parent', name: 'Parent' }, { id: 'child', name: 'Child', parentId: 'parent' }, { id: 'other', name: 'Other' }],
+ });
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['inbox', 'shared:parent', 'shared:child', 'shared:other']);
+ expect(result.states).toEqual({ primary: 'm', shared: 'm' });
+ expect(vi.mocked(client['request']).mock.calls[0][0].map(([name]) => name)).toEqual(['Mailbox/get', 'Mailbox/get']);
+ });
+
+ it('does not drop later accounts in a packed batch when one paginated account fails', async () => {
+ const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'a' }, { id: 'b' }] }, '0'],
+ ['Mailbox/get', { state: 'shared-state', list: [{ id: 'inbox', name: 'Shared Inbox' }] }, '1'],
+ ] })
+ .mockRejectedValueOnce(new Error('page failed'));
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['shared:inbox']);
+ expect(result.states).toEqual({ shared: 'shared-state' });
+ });
+
+ it('pages when an unbounded get is rejected as tooManyObjects', async () => {
+ const client = createClient();
+ const list = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }];
+ serve(client, { primary: list });
+ vi.mocked(client['request']).mockResolvedValueOnce({ methodResponses: [
+ ['error', { type: 'tooManyObjects' }, '0'],
+ ] });
+ expect((await client.getMailboxes()).map(({ id }) => id)).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 4893af357..eb4ea01b8 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -2159,11 +2159,13 @@ export class JMAPClient implements IJMAPClient {
}
/**
+ * `skipOfflineRecord`: mailbox pagination records the complete list once,
+ * never a capped initial response or an individual page.
* `transfer`: a mail import or export's own request. A 429 throws
* RateLimitError for the transfer to wait on, without pausing the rest of
* the client, and a pause the client is in doesn't hold the transfer up.
*/
- private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise {
+ private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean; skipOfflineRecord?: boolean }): Promise {
if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.');
}
@@ -2193,7 +2195,7 @@ export class JMAPClient implements IJMAPClient {
}
if (offline) {
offline.noteNetwork(this, true);
- offline.record(this, methodCalls, answer);
+ if (!opts?.skipOfflineRecord) offline.record(this, methodCalls, answer);
}
return answer;
}
@@ -2270,37 +2272,98 @@ export class JMAPClient implements IJMAPClient {
}
}
+ /**
+ * Mailbox/get without ids can silently stop at maxObjectsInGet, leaving
+ * children without their parents. Querying all ids keeps the tree complete.
+ */
+ private async fetchMailboxTree(accountId: string): Promise {
+ const mailboxes: JMAPMailbox[] = [];
+ const seenIds = new Set();
+ let position = 0;
+ let total: number | undefined;
+ let queryState: string | undefined;
+ let mailboxState: string | undefined;
+
+ let hasMorePages = true;
+ while (hasMorePages) {
+ const response = await this.request([
+ ["Mailbox/query", {
+ accountId,
+ position,
+ limit: this.getMaxObjectsInGet(),
+ calculateTotal: position === 0,
+ }, "query"],
+ ["Mailbox/get", {
+ accountId,
+ properties: MAILBOX_PROPERTIES,
+ "#ids": { resultOf: "query", name: "Mailbox/query", path: "/ids" },
+ }, "get"],
+ ], undefined, { skipOfflineRecord: true });
+
+ const queryResponse = response.methodResponses?.[0];
+ const getResponse = response.methodResponses?.[1];
+ const ids = queryResponse?.[1]?.ids;
+ const pageQueryState = queryResponse?.[1]?.queryState;
+ const page = getResponse?.[1]?.list;
+ const pageMailboxState = getResponse?.[1]?.state;
+
+ if (
+ queryResponse?.[0] !== "Mailbox/query" ||
+ !Array.isArray(ids) ||
+ !ids.every((id: unknown) => typeof id === "string") ||
+ typeof pageQueryState !== "string" ||
+ getResponse?.[0] !== "Mailbox/get" ||
+ !Array.isArray(page) ||
+ getResponse[1].notFound?.length ||
+ page.length !== ids.length
+ ) {
+ throw new Error("Failed to load the complete mailbox list");
+ }
+
+ if (queryState !== undefined && pageQueryState !== queryState) {
+ throw new Error("Mailbox list changed while it was being loaded");
+ }
+ if (mailboxState !== undefined && pageMailboxState !== undefined && pageMailboxState !== mailboxState) {
+ throw new Error("Mailbox list changed while it was being loaded");
+ }
+ queryState = pageQueryState;
+ if (typeof pageMailboxState === "string") mailboxState = pageMailboxState;
+ for (const id of ids) {
+ if (seenIds.has(id)) throw new Error("Mailbox/query returned duplicate ids");
+ seenIds.add(id);
+ }
+ mailboxes.push(...page);
+ if (typeof queryResponse[1].total === "number") total = queryResponse[1].total;
+ position += ids.length;
+ hasMorePages = ids.length > 0 && (total === undefined || position < total);
+ }
+
+ if (total !== undefined && seenIds.size !== total) {
+ throw new Error(`Mailbox/query returned ${seenIds.size} of ${total} mailboxes`);
+ }
+
+ return { methodResponses: [["Mailbox/get", { list: mailboxes, state: mailboxState }, "0"]] };
+ }
+
async getMailboxes(accountId?: string): Promise {
const acctId = accountId || this.accountId;
try {
- const response = await this.request([
- ["Mailbox/get", { accountId: acctId, properties: MAILBOX_PROPERTIES }, "0"]
- ]);
+ const calls: JMAPMethodCall[] = [
+ ["Mailbox/get", { accountId: acctId, properties: MAILBOX_PROPERTIES }, "0"],
+ ];
+ let response = await this.request(calls, undefined, { skipOfflineRecord: true });
+ const first = response.methodResponses?.[0];
+ if ((first?.[0] === "Mailbox/get" && first[1].list?.length >= this.getMaxObjectsInGet()) ||
+ (first?.[0] === "error" && first[1].type === "tooManyObjects")) {
+ response = await this.fetchMailboxTree(acctId);
+ }
+ getOfflineHooks()?.record(this, calls, response);
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${acctId}`);
- // Warn if response might be truncated
- const maxObjects = this.getMaxObjectsInGet();
- if (rawMailboxes.length >= maxObjects) {
- debug.warn('jmap',
- `[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
- `Some mailboxes may be missing - nested folders could appear orphaned at root level.`
- );
- }
-
- // Log parentId references to detect potential orphans
- const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
- const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
- if (missingParents.length > 0) {
- debug.warn('jmap',
- `[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
- missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
- );
- }
-
return rawMailboxes.map((mb) => ({
id: mb.id,
originalId: undefined,
@@ -2370,14 +2433,31 @@ export class JMAPClient implements IJMAPClient {
"Mailbox/get",
{ accountId, properties: MAILBOX_PROPERTIES },
String(i),
- ]));
+ ]), undefined, { skipOfflineRecord: true });
- batch.forEach((accountId, i) => {
- const match = response.methodResponses?.find((r) => r[2] === String(i));
+ for (const [i, accountId] of batch.entries()) {
+ let match = response.methodResponses?.find((r) => r[2] === String(i));
+ // Keep the packed fast path for small accounts. A full-sized result
+ // may be capped, so reload that account through query/get pages.
+ if ((match?.[0] === "Mailbox/get" && Array.isArray(match[1].list) && match[1].list.length >= maxObjects) ||
+ (match?.[0] === "error" && match[1].type === "tooManyObjects")) {
+ try {
+ const complete = await this.fetchMailboxTree(accountId);
+ match = complete.methodResponses[0];
+ } catch (error) {
+ fetchFailed = true;
+ console.error(`Failed to fetch all mailboxes for account ${accountId}:`, error);
+ continue;
+ }
+ }
if (match?.[0] !== "Mailbox/get") {
if (match) debug.warn('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} answered ${match[0]}`, match[1]);
- return;
+ continue;
}
+ getOfflineHooks()?.record(this,
+ [["Mailbox/get", { accountId, properties: MAILBOX_PROPERTIES }, "0"]],
+ { methodResponses: [["Mailbox/get", match[1], "0"]] },
+ );
const rawMailboxes = (match[1].list || []) as JMAPMailbox[];
const mailboxState = (match[1] as { state?: unknown }).state;
if (typeof mailboxState === 'string') {
@@ -2386,16 +2466,8 @@ export class JMAPClient implements IJMAPClient {
debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${accountId === this.accountId})`);
- // Warn if response might be truncated
- if (rawMailboxes.length >= maxObjects) {
- debug.warn('jmap',
- `[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
- `Some mailboxes may be missing.`
- );
- }
-
perAccount[offset + i] = rawMailboxes.map((mb) => this.mapRawMailbox(mb, accountId));
- });
+ }
} catch (error) {
fetchFailed = true;
console.error(`Failed to fetch mailboxes for accounts ${batch.join(', ')}:`, error);
diff --git a/lib/offline/__tests__/client-fallback.test.ts b/lib/offline/__tests__/client-fallback.test.ts
index d710d1c77..f7d49e49b 100644
--- a/lib/offline/__tests__/client-fallback.test.ts
+++ b/lib/offline/__tests__/client-fallback.test.ts
@@ -6,7 +6,7 @@ import { useAccountStore } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { startOfflineMode, wipeEverything } from '../offline-mode';
-import { forgetIndexes, putEmails, putMisc, emailIndex } from '../cache';
+import { forgetIndexes, putEmails, putMisc, getMisc, emailIndex } from '../cache';
import { forgetDeviceKey } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
import { useOfflineStore, writeEnabled } from '../state';
@@ -88,6 +88,98 @@ describe('falling back to the device', () => {
});
});
+describe('mailbox paging with the device cache', () => {
+ const mailboxes = Array.from({ length: 5 }, (_, i) => ({ id: `box-${i}`, name: `Folder ${i}`, parentId: null }));
+
+ function mailboxServer(failLaterPage = false) {
+ const positions: number[] = [];
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
+ if (String(url).endsWith('/.well-known/jmap')) {
+ return new Response(JSON.stringify({
+ ...SESSION,
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2 } },
+ }), { status: 200 });
+ }
+ const { methodCalls } = JSON.parse(String(init?.body));
+ const query = methodCalls.find(([name]: [string]) => name === 'Mailbox/query');
+ const position = query?.[1].position ?? 0;
+ if (query) positions.push(position);
+ const page = mailboxes.slice(position, position + 2);
+ const methodResponses = methodCalls.map(([name, , id]: [string, unknown, string]) => {
+ if (name === 'Identity/get') return [name, { accountId: 'j1', list: [{ id: 'recording-barrier' }] }, id];
+ if (query && failLaterPage && position > 0) return ['error', { type: 'serverFail' }, id];
+ if (name === 'Mailbox/query') return [name, {
+ accountId: 'j1', queryState: 'query-state', position,
+ ids: page.map((box) => box.id), total: mailboxes.length,
+ }, id];
+ return [name, { accountId: 'j1', state: 'mailbox-state', list: page, notFound: [] }, id];
+ });
+ return new Response(JSON.stringify({ methodResponses }), { status: 200 });
+ }));
+ return positions;
+ }
+
+ // Identity recording shares the per-account queue with mailbox recording.
+ // Waiting for this marker also detects erroneous writes queued by failed pages.
+ async function recorded(client: JMAPClient) {
+ await client.getIdentities();
+ await vi.waitFor(async () => {
+ expect(await getMisc('acc', 'identities')).toEqual({ j1: [{ id: 'recording-barrier' }] });
+ });
+ }
+
+ it('returns a cached list larger than maxObjectsInGet without inventing a collection state', async () => {
+ mailboxServer();
+ const client = await connectedClient();
+ writeEnabled(['acc']);
+ const stop = startOfflineMode();
+ try {
+ await putMisc('acc', 'mailboxes', { j1: mailboxes });
+ vi.stubGlobal('navigator', { onLine: false });
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map((box) => box.id)).toEqual(mailboxes.map((box) => box.id));
+ expect(result.states).toEqual({});
+ // Only session discovery touched the network; both query/get pages are local.
+ expect(fetch).toHaveBeenCalledTimes(1);
+ } finally {
+ stop();
+ }
+ });
+
+ it('records the complete live multi-page list rather than its final page', async () => {
+ const positions = mailboxServer();
+ const client = await connectedClient();
+ writeEnabled(['acc']);
+ const stop = startOfflineMode();
+ try {
+ const result = await client.getMailboxes();
+ expect(result.map((box) => box.id)).toEqual(mailboxes.map((box) => box.id));
+ expect(positions).toEqual([0, 2, 4]);
+ await recorded(client);
+ expect(await getMisc('acc', 'mailboxes')).toEqual({ j1: mailboxes });
+ } finally {
+ stop();
+ }
+ });
+
+ it('retains the previous complete cache when a later live page fails', async () => {
+ const positions = mailboxServer(true);
+ const client = await connectedClient();
+ writeEnabled(['acc']);
+ const stop = startOfflineMode();
+ const previous = [{ id: 'old', name: 'Previously cached', parentId: null }];
+ try {
+ await putMisc('acc', 'mailboxes', { j1: previous });
+ await expect(client.getMailboxes()).rejects.toThrow('Failed to load the complete mailbox list');
+ expect(positions).toEqual([0, 2]);
+ await recorded(client);
+ expect(await getMisc('acc', 'mailboxes')).toEqual({ j1: previous });
+ } finally {
+ stop();
+ }
+ });
+});
+
describe('the sync', () => {
function fakeClient(opts: { failQuery?: boolean }) {
return {
diff --git a/lib/offline/responder.ts b/lib/offline/responder.ts
index d0d4ac7e7..9495ee9a2 100644
--- a/lib/offline/responder.ts
+++ b/lib/offline/responder.ts
@@ -9,7 +9,7 @@
* is refused, as are calls the cache can't answer (search, changes since a
* state): the whole request then fails as it would without offline mode.
*
- * Answers carry no state strings: a made-up one would end up in an
+ * Answers carry no collection state strings: a made-up one would end up in an
* Email/changes once the server is back, which refuses it.
*/
@@ -267,6 +267,23 @@ async function listGet(ctx: ResponderContext, args: Record, nam
};
}
+/** Unfiltered mailbox pages used to load a complete folder list offline. */
+async function mailboxQuery(ctx: ResponderContext, args: Record): Promise {
+ if (args.filter || args.sort || args.anchor || args.sortAsTree) throw new Unanswerable();
+ const { list } = await listGet(ctx, { accountId: args.accountId }, 'mailboxes');
+ const mailboxes = list as Record[];
+ const position = typeof args.position === 'number' ? args.position : 0;
+ const limit = typeof args.limit === 'number' ? args.limit : mailboxes.length;
+ return {
+ accountId: (args.accountId as string) || ctx.defaultJmap,
+ // A query token only, never a Mailbox collection state for /changes.
+ queryState: JSON.stringify(mailboxes),
+ position,
+ ids: mailboxes.slice(position, position + limit).map((mailbox) => mailbox.id),
+ ...(args.calculateTotal ? { total: mailboxes.length } : {}),
+ };
+}
+
/** Whether a PatchObject only changes what offline mode may queue. */
function queueablePatch(patch: Record): boolean {
return Object.keys(patch).length > 0 && Object.keys(patch).every((path) =>
@@ -361,6 +378,7 @@ export async function answerOffline(ctx: ResponderContext, methodCalls: OfflineM
case 'Email/query': result = await emailQuery(ctx, args); break;
case 'Email/get': result = await emailGet(ctx, args); break;
case 'Thread/get': result = await threadGet(ctx, args); break;
+ case 'Mailbox/query': result = await mailboxQuery(ctx, args); break;
case 'Mailbox/get': result = await listGet(ctx, args, 'mailboxes'); break;
case 'Identity/get': result = await listGet(ctx, args, 'identities'); break;
case 'Email/set': result = await emailSet(ctx, args); break;
From c862909fddc3ec061346dddc767b97d9cd3b2c37 Mon Sep 17 00:00:00 2001
From: Nick Sweeting
Date: Mon, 2 Nov 2026 10:50:50 -0800
Subject: [PATCH 10/21] fix: respect single-call paging and preserve folders on
page failures
---
lib/__tests__/jmap-mailbox-pagination.test.ts | 31 ++++++++--
lib/jmap/client.ts | 38 +++++++++---
.../email-store-mailbox-pagination.test.ts | 61 +++++++++++++++++++
3 files changed, 117 insertions(+), 13 deletions(-)
create mode 100644 stores/__tests__/email-store-mailbox-pagination.test.ts
diff --git a/lib/__tests__/jmap-mailbox-pagination.test.ts b/lib/__tests__/jmap-mailbox-pagination.test.ts
index 461a2bee6..60cf0ea4c 100644
--- a/lib/__tests__/jmap-mailbox-pagination.test.ts
+++ b/lib/__tests__/jmap-mailbox-pagination.test.ts
@@ -131,7 +131,7 @@ describe('JMAP mailbox pagination', () => {
expect(vi.mocked(client['request']).mock.calls[0][0].map(([name]) => name)).toEqual(['Mailbox/get', 'Mailbox/get']);
});
- it('does not drop later accounts in a packed batch when one paginated account fails', async () => {
+ it('rejects a partial refresh when one paginated account fails', async () => {
const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(client as unknown as RequestTarget, 'request')
@@ -140,9 +140,7 @@ describe('JMAP mailbox pagination', () => {
['Mailbox/get', { state: 'shared-state', list: [{ id: 'inbox', name: 'Shared Inbox' }] }, '1'],
] })
.mockRejectedValueOnce(new Error('page failed'));
- const result = await client.getAllMailboxesWithState();
- expect(result.mailboxes.map(({ id }) => id)).toEqual(['shared:inbox']);
- expect(result.states).toEqual({ shared: 'shared-state' });
+ await expect(client.getAllMailboxesWithState()).rejects.toThrow('complete mailbox list');
});
it('pages when an unbounded get is rejected as tooManyObjects', async () => {
@@ -154,4 +152,29 @@ describe('JMAP mailbox pagination', () => {
] });
expect((await client.getMailboxes()).map(({ id }) => id)).toEqual(['a', 'b', 'c']);
});
+
+ it.each([false, true])('respects maxCallsInRequest=1 (shared: %s)', async (shared) => {
+ const client = createClient(2, shared ? { primary: { name: 'Self' }, shared: { name: 'Shared' } } : undefined);
+ Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2, maxCallsInRequest: 1 } } });
+ const boxes = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }];
+ const spy = vi.spyOn(client as unknown as RequestTarget, 'request').mockImplementation(async (calls) => {
+ expect(calls).toHaveLength(1);
+ const [name, args, tag] = calls[0];
+ if (name === 'Mailbox/query') {
+ const ids = boxes.slice(args.position as number, (args.position as number) + 2).map(box => box.id);
+ return { methodResponses: [[name, { ids, queryState: 'q', total: 3 }, tag]] };
+ }
+ const ids = args.ids as string[] | undefined;
+ expect(args['#ids']).toBeUndefined();
+ const list = ids ? boxes.filter(box => ids.includes(box.id)) : boxes.slice(0, 2);
+ return { methodResponses: [[name, { list, state: 'm', notFound: [] }, tag]] };
+ });
+ const result = shared ? await client.getAllMailboxes() : await client.getMailboxes();
+ expect(result).toHaveLength(shared ? 6 : 3);
+ const gets = spy.mock.calls.flatMap(([calls]) => calls).filter(([name, args]) => name === 'Mailbox/get' && args.ids);
+ for (const accountId of shared ? ['primary', 'shared'] : ['primary']) {
+ expect(gets.filter(([, args]) => args.accountId === accountId).map(([, args]) => args.ids))
+ .toEqual([['a', 'b'], ['c']]);
+ }
+ });
});
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index eb4ea01b8..1f02ee7df 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -2286,22 +2286,36 @@ export class JMAPClient implements IJMAPClient {
let hasMorePages = true;
while (hasMorePages) {
- const response = await this.request([
- ["Mailbox/query", {
- accountId,
- position,
- limit: this.getMaxObjectsInGet(),
- calculateTotal: position === 0,
- }, "query"],
+ const queryCall: JMAPMethodCall = ["Mailbox/query", {
+ accountId,
+ position,
+ limit: this.getMaxObjectsInGet(),
+ calculateTotal: position === 0,
+ }, "query"];
+ const combined = this.getMaxCallsInRequest() > 1;
+ const response = await this.request(combined ? [queryCall,
["Mailbox/get", {
accountId,
properties: MAILBOX_PROPERTIES,
"#ids": { resultOf: "query", name: "Mailbox/query", path: "/ids" },
}, "get"],
- ], undefined, { skipOfflineRecord: true });
+ ] : [queryCall], undefined, { skipOfflineRecord: true });
const queryResponse = response.methodResponses?.[0];
- const getResponse = response.methodResponses?.[1];
+ let getResponse = response.methodResponses?.[1];
+ if (!combined) {
+ const queriedIds = queryResponse?.[1]?.ids;
+ if (queryResponse?.[0] !== "Mailbox/query" || !Array.isArray(queriedIds) ||
+ !queriedIds.every((id: unknown) => typeof id === "string")) {
+ throw new Error("Failed to load the complete mailbox list");
+ }
+ // Result references cannot cross requests. Respect single-call servers
+ // by passing this page's IDs explicitly, still deferring cache writes.
+ const fetched = await this.request([["Mailbox/get", {
+ accountId, properties: MAILBOX_PROPERTIES, ids: queriedIds,
+ }, "get"]], undefined, { skipOfflineRecord: true });
+ getResponse = fetched.methodResponses?.[0];
+ }
const ids = queryResponse?.[1]?.ids;
const pageQueryState = queryResponse?.[1]?.queryState;
const page = getResponse?.[1]?.list;
@@ -2417,6 +2431,7 @@ export class JMAPClient implements IJMAPClient {
}
let fetchFailed = false;
+ let paginationFailed = false;
// One Mailbox/get per account, packed maxCallsInRequest to a request and
// a few requests in flight at once. One request per account, one after
@@ -2446,6 +2461,7 @@ export class JMAPClient implements IJMAPClient {
match = complete.methodResponses[0];
} catch (error) {
fetchFailed = true;
+ paginationFailed = true;
console.error(`Failed to fetch all mailboxes for account ${accountId}:`, error);
continue;
}
@@ -2486,6 +2502,10 @@ export class JMAPClient implements IJMAPClient {
);
await Promise.all(workers);
+ // The store replaces the whole folder list on a successful refresh.
+ // Never publish sibling accounts as a complete list after paging fails.
+ if (paginationFailed) throw new Error('Failed to load the complete mailbox list');
+
// Accounts keep the session's order, whichever batch answered first.
for (const mailboxes of perAccount) allMailboxes.push(...mailboxes);
diff --git a/stores/__tests__/email-store-mailbox-pagination.test.ts b/stores/__tests__/email-store-mailbox-pagination.test.ts
new file mode 100644
index 000000000..cb04d9659
--- /dev/null
+++ b/stores/__tests__/email-store-mailbox-pagination.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '@/lib/jmap/client';
+import type { Mailbox } from '@/lib/jmap/types';
+import { useEmailStore } from '../email-store';
+
+const initialState = useEmailStore.getState();
+type MethodCall = [string, Record, string];
+type RequestTarget = { request(calls: MethodCall[]): Promise };
+
+beforeEach(() => useEmailStore.setState(initialState, true));
+afterEach(() => {
+ vi.restoreAllMocks();
+ useEmailStore.setState(initialState, true);
+});
+
+describe('failed mailbox pagination during a store refresh', () => {
+ it('preserves the entire folder list, collection states, and selection when another account succeeds', async () => {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, {
+ apiUrl: 'https://jmap.example.com/api',
+ accountId: 'primary',
+ accounts: { primary: { name: 'Self' }, shared: { name: 'Shared' } },
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2 } },
+ });
+ const previousMailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', accountId: 'primary', isShared: false },
+ { id: 'child', name: 'Selected folder', parentId: 'inbox', accountId: 'primary', isShared: false },
+ { id: 'shared:inbox', originalId: 'inbox', name: 'Shared Inbox', accountId: 'shared', isShared: true },
+ ] as Mailbox[];
+ const previousStates = { primary: 'old-primary', shared: 'old-shared' };
+ useEmailStore.setState({
+ mailboxes: previousMailboxes,
+ mailboxSyncStates: previousStates,
+ selectedMailbox: 'child',
+ isLoading: false,
+ error: null,
+ });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const request = vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'inbox' }, { id: 'child' }], state: 'new-primary' }, '0'],
+ ['Mailbox/get', { list: [{ id: 'inbox', name: 'Shared Inbox', role: 'inbox' }], state: 'new-shared' }, '1'],
+ ] })
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/query', { ids: ['inbox', 'child'], queryState: 'q', total: 3 }, 'query'],
+ ['Mailbox/get', { list: [{ id: 'inbox', name: 'Inbox' }, { id: 'child', name: 'Selected folder' }], state: 'new-primary', notFound: [] }, 'get'],
+ ] })
+ .mockRejectedValueOnce(new Error('Later mailbox page failed'));
+
+ await useEmailStore.getState().fetchMailboxes(client);
+
+ expect(request).toHaveBeenCalledTimes(3);
+ expect(request.mock.calls[2][0][0][1].position).toBe(2);
+ const state = useEmailStore.getState();
+ expect(state.mailboxes).toBe(previousMailboxes);
+ expect(state.mailboxSyncStates).toBe(previousStates);
+ expect(state.selectedMailbox).toBe('child');
+ expect(state.isLoading).toBe(false);
+ expect(state.error).toBeTruthy();
+ });
+});
From 52330cbd07174837c8c56f6fc24855ce6a17c7db Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:14:48 +0100
Subject: [PATCH 11/21] fix: keep inserting signatures for existing users by
default #1493
---
stores/__tests__/settings-store-sync.test.ts | 31 ++++++++++++++++----
stores/settings-store.ts | 6 ++--
2 files changed, 30 insertions(+), 7 deletions(-)
diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts
index 0a0ad153a..671179a15 100644
--- a/stores/__tests__/settings-store-sync.test.ts
+++ b/stores/__tests__/settings-store-sync.test.ts
@@ -52,20 +52,41 @@ describe('settings sync debounce', () => {
});
});
- it('defaults the signature auto-insert toggles and the separator to off, and exports all three', () => {
+ it('defaults the signature auto-insert toggles to on and exports them', () => {
const settings = useSettingsStore.getState();
- expect(settings.signatureNewMailEnabled).toBe(false);
- expect(settings.signatureReplyForwardEnabled).toBe(false);
+ expect(settings.signatureNewMailEnabled).toBe(true);
+ expect(settings.signatureReplyForwardEnabled).toBe(true);
expect(settings.signatureSeparatorEnabled).toBe(false);
const exported = JSON.parse(settings.exportSettings()) as Record;
expect(exported).toMatchObject({
- signatureNewMailEnabled: false,
- signatureReplyForwardEnabled: false,
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: false,
});
});
+ it('keeps inserting the signature for users whose saved settings predate the toggles', async () => {
+ useSettingsStore.getState().resetToDefaults();
+ // Saved by a build without the toggles: the keys are missing.
+ localStorage.setItem('settings-storage', JSON.stringify({
+ state: { signaturePosition: 'below_quote', signatureSeparatorEnabled: true },
+ version: 12,
+ }));
+ await useSettingsStore.persist.rehydrate();
+ expect(useSettingsStore.getState().signatureNewMailEnabled).toBe(true);
+ expect(useSettingsStore.getState().signatureReplyForwardEnabled).toBe(true);
+
+ // A server copy written by such a build has no toggles either.
+ useSettingsStore.getState().importSettings(
+ JSON.stringify({ signaturePosition: 'above_quote' }),
+ { serverAccountId: 'a@example.com' },
+ );
+ expect(useSettingsStore.getState().signatureNewMailEnabled).toBe(true);
+ expect(useSettingsStore.getState().signatureReplyForwardEnabled).toBe(true);
+ localStorage.removeItem('settings-storage');
+ });
+
it('does not rewrite a pending account snapshot after another account becomes active', async () => {
const settings = useSettingsStore.getState();
settings.enableSync('a@example.com', 'https://mail-a.example.com');
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 56b69fa93..fa5cd60aa 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -932,8 +932,10 @@ const DEFAULT_SETTINGS = {
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition,
- signatureNewMailEnabled: false,
- signatureReplyForwardEnabled: false,
+ // On, as before the toggles existed: saved settings and server copies
+ // from older builds lack these keys and fall back to this default.
+ signatureNewMailEnabled: true,
+ signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: false,
signatureDetails: EMPTY_SIGNATURE_DETAILS,
requestReadReceiptDefault: false,
From 14e1ab5f26035c5d99ac87eaa73d7a6806cecbe2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:14:49 +0100
Subject: [PATCH 12/21] test: cover quick reply with the reply signature toggle
off #1493
---
lib/outgoing/__tests__/quick-reply.test.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/lib/outgoing/__tests__/quick-reply.test.ts b/lib/outgoing/__tests__/quick-reply.test.ts
index 3ff01cb8d..e8b6e9d8a 100644
--- a/lib/outgoing/__tests__/quick-reply.test.ts
+++ b/lib/outgoing/__tests__/quick-reply.test.ts
@@ -33,6 +33,14 @@ describe('prepareQuickReply', () => {
expect(plain?.text).toMatch(/Thanks <3[\s\S]*-- \nMe$/);
});
+ it('sends the bare text when the reply signature toggle is off', async () => {
+ const withHtml = await prepareQuickReply({ ...input(html), insertSignature: false });
+ expect(withHtml?.html).toBeUndefined();
+ expect(withHtml?.text).toBe('Thanks <3');
+ const plain = await prepareQuickReply({ ...input(text), insertSignature: false });
+ expect(plain?.text).toBe('Thanks <3');
+ });
+
it('runs the finalisers with a reply context', async () => {
const contexts: unknown[] = [];
registerOutgoingFinalizer('f', 10, (bodies, context) => {
From d70f058ec733cc1a816c4c9ce5520ee1ef94985a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:15:13 +0100
Subject: [PATCH 13/21] fix: keep the signature delimiter on by default for new
users #1493
---
stores/__tests__/settings-store-sync.test.ts | 6 +++---
stores/settings-store.ts | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts
index 671179a15..fc7462732 100644
--- a/stores/__tests__/settings-store-sync.test.ts
+++ b/stores/__tests__/settings-store-sync.test.ts
@@ -52,17 +52,17 @@ describe('settings sync debounce', () => {
});
});
- it('defaults the signature auto-insert toggles to on and exports them', () => {
+ it('defaults the signature auto-insert toggles and the separator to on and exports them', () => {
const settings = useSettingsStore.getState();
expect(settings.signatureNewMailEnabled).toBe(true);
expect(settings.signatureReplyForwardEnabled).toBe(true);
- expect(settings.signatureSeparatorEnabled).toBe(false);
+ expect(settings.signatureSeparatorEnabled).toBe(true);
const exported = JSON.parse(settings.exportSettings()) as Record;
expect(exported).toMatchObject({
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
- signatureSeparatorEnabled: false,
+ signatureSeparatorEnabled: true,
});
});
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index fa5cd60aa..d98349b56 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -936,7 +936,7 @@ const DEFAULT_SETTINGS = {
// from older builds lack these keys and fall back to this default.
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
- signatureSeparatorEnabled: false,
+ signatureSeparatorEnabled: true,
signatureDetails: EMPTY_SIGNATURE_DETAILS,
requestReadReceiptDefault: false,
readReceiptResponse: 'ask' as ReadReceiptResponse,
From f1b2c20f1d071eb6899c66d3167609dc81dfeb86 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:15:29 +0100
Subject: [PATCH 14/21] chore: undo stray formatting changes from the signature
toggles #1493
---
components/mail/mail-app.tsx | 1 +
lib/settings-search.ts | 3 ++-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 478819513..9d3449ba6 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -4143,6 +4143,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
if (!prepared) return false;
const { html: finalHtmlBody, text: finalBody } = prepared;
+
const originalEmailId = selectedEmail.id;
const originalSource = emailWriteSource(selectedEmail, client);
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
diff --git a/lib/settings-search.ts b/lib/settings-search.ts
index e94207411..3c14bbc11 100644
--- a/lib/settings-search.ts
+++ b/lib/settings-search.ts
@@ -360,7 +360,8 @@ export const tabSearchEntries: Record = {
{ label: 'settings.advanced.refresh_cache.label' },
{ label: 'settings.advanced.reset_settings.label' },
{ label: 'settings.advanced.debug_mode.label', advanced: true },
- ],};
+ ],
+};
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
From 0c421f555783ea983bddda61447abb3a1a3adcaa Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:15:33 +0100
Subject: [PATCH 15/21] test: cover opening the shared inbox from a grouped
notification #1499
---
.../mail/__tests__/mail-app-source-routing.test.tsx | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/components/mail/__tests__/mail-app-source-routing.test.tsx b/components/mail/__tests__/mail-app-source-routing.test.tsx
index 329c8c0a1..add5d3d12 100644
--- a/components/mail/__tests__/mail-app-source-routing.test.tsx
+++ b/components/mail/__tests__/mail-app-source-routing.test.tsx
@@ -281,4 +281,16 @@ describe('shared message deep link from the personal Inbox (#1491)', () => {
window.history.replaceState(null, '', '/');
}
});
+
+ it('opens the shared Inbox for a grouped notification of the shared account', async () => {
+ window.history.replaceState(null, '', '/mail/folder/inbox?owner=ce');
+ useEmailStore.setState({ emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null } as never);
+ const mounted = render();
+ try {
+ await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
+ } finally {
+ mounted.unmount();
+ window.history.replaceState(null, '', '/');
+ }
+ });
});
From 244e8b7b5aa10b049246ca51daed5d3eba02001d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:15:34 +0100
Subject: [PATCH 16/21] fix: keep the login and mailbox owner in notification
links on safari 16 #1499
---
lib/__tests__/helpers/push-click.ts | 8 ++++++--
lib/__tests__/sw-push-owner.test.ts | 5 +++++
public/sw.js | 4 +++-
3 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/lib/__tests__/helpers/push-click.ts b/lib/__tests__/helpers/push-click.ts
index 7509a9c73..369efd6a2 100644
--- a/lib/__tests__/helpers/push-click.ts
+++ b/lib/__tests__/helpers/push-click.ts
@@ -5,7 +5,7 @@ import vm from 'node:vm';
export async function pushClick(
accountId: string,
preview: (url: string) => Promise,
- options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string } = {},
+ options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {},
): Promise {
const source = readFileSync(resolve('public/sw.js'), 'utf8');
let notification: { data: unknown } | undefined;
@@ -18,8 +18,12 @@ export async function pushClick(
async focus() { return this; },
async navigate(url: string) { target = url; return this; },
};
+ // Safari before 17 has no URLSearchParams.size.
+ const SearchParams = options.noParamsSize
+ ? class extends URLSearchParams { get size(): number { return undefined as unknown as number; } }
+ : URLSearchParams;
const context = vm.createContext({
- URL, URLSearchParams, Response, AbortController, setTimeout, clearTimeout, console,
+ URL, URLSearchParams: SearchParams, Response, AbortController, setTimeout, clearTimeout, console,
self: {
location: { href: `https://mail.example.test${options.basePath ?? ''}/sw.js`, origin: 'https://mail.example.test' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
diff --git a/lib/__tests__/sw-push-owner.test.ts b/lib/__tests__/sw-push-owner.test.ts
index ace7cf58d..ade1e246a 100644
--- a/lib/__tests__/sw-push-owner.test.ts
+++ b/lib/__tests__/sw-push-owner.test.ts
@@ -47,6 +47,11 @@ describe('push click keeps the owning mailbox (#1491)', () => {
expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 });
});
+ it('keeps the slot and owner where URLSearchParams has no size', async () => {
+ const target = await pushClick('team', async () => ({ slot: 1, unreadTotal: 1, email: { id: 'same-id' } }), { noParamsSize: true });
+ expect(parseTarget(target)).toMatchObject({ kind: 'message', ownerAccountId: 'team', slot: 1 });
+ });
+
it('does not fall back to a personal folder for an unavailable owner', () => {
expect(resolveFolderRef('inbox', folders, 'unavailable')).toBeNull();
});
diff --git a/public/sw.js b/public/sw.js
index 975db0c27..3dc7ff7b8 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -925,7 +925,9 @@ function buildClickUrl(data) {
const params = new URLSearchParams();
if (Number.isInteger(data.slot)) params.set("slot", String(data.slot));
if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId);
- const targetQuery = params.size ? `?${params}` : "";
+ // Not params.size: Safari before 17 (iOS 16.4+ web push) lacks it.
+ const qs = params.toString();
+ const targetQuery = qs ? `?${qs}` : "";
if (data.kind === "email" && data.emailId) {
// Permalink (#733). Under NEXT_PUBLIC_LOCALE_PREFIX=always the proxy
// redirects this to the localised path; the worker has no locale to add.
From 29e7a5b8c78c6e351e48cd0865d2f0fae7de3318 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:15:59 +0100
Subject: [PATCH 17/21] fix: give plugins the birthdays whose day overlaps the
asked range #1479
---
lib/__tests__/plugin-host-calendar-tasks.test.ts | 13 +++++++++++++
lib/plugin-sandbox/host-api-calendar.ts | 12 +++++++++++-
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts
index a050a9a40..03d20d6c3 100644
--- a/lib/__tests__/plugin-host-calendar-tasks.test.ts
+++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts
@@ -261,6 +261,19 @@ describe('api.calendar', () => {
}) as { calendarId: string }[];
expect(afterBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+ // The end is exclusive, as for regular events: a week ending at the
+ // birthday's midnight does not contain it.
+ const weekEndingOnBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-08', to: '2026-05-15',
+ }) as { calendarId: string }[];
+ expect(weekEndingOnBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // A birthday covers its whole day, so a range inside that day finds it.
+ const middayOnBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-15T10:00', to: '2026-05-15T12:00',
+ }) as { id: string }[];
+ expect(middayOnBirthday.map((e) => e.id)).toContain('birthday-c1-b1-2026');
+
// Respects calendarIds filtering
const onlyWork = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
from: '2026-05-01', to: '2026-06-01', calendarIds: ['cal-work'],
diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts
index 2b6733fba..ce6d16bf5 100644
--- a/lib/plugin-sandbox/host-api-calendar.ts
+++ b/lib/plugin-sandbox/host-api-calendar.ts
@@ -6,6 +6,7 @@
// Plugins describe events in a small shape of their own (CalendarEventInput
// in plugin-types.ts); arbitrary JSCalendar objects are not accepted.
+import { addDays, parseISO, subDays } from 'date-fns';
import { createRecurrenceRule } from '@/lib/recurrence-rule';
import type {
CalendarEventInput, CalendarEventView, CalendarView, InstalledPlugin, TaskInput, TaskView,
@@ -305,7 +306,16 @@ export async function doCalendarGetEvents(options: unknown, plugin?: InstalledPl
const { showBirthdayCalendar } = useSettingsStore.getState();
if (showBirthdayCalendar && canReadContacts && (!calendarIds || calendarIds.has(BIRTHDAY_CALENDAR_ID))) {
const contacts = await ensureContacts(client);
- const birthdays = generateBirthdayEvents(contacts, start, end);
+ // A birthday lasts its whole day. Keep the ones whose day overlaps the
+ // range the way the server does for regular events: the day ends after
+ // `from` and starts before `to`, so `to` itself is excluded.
+ const rangeStart = parseISO(start);
+ const rangeEnd = parseISO(end);
+ const birthdays = generateBirthdayEvents(contacts, subDays(rangeStart, 1).toISOString(), end)
+ .filter((b) => {
+ const dayStart = parseISO(b.start);
+ return dayStart < rangeEnd && addDays(dayStart, 1) > rangeStart;
+ });
events.push(...birthdays);
}
From 1a3306322f110c5856aef71b3a9032b8df544900 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:17:11 +0100
Subject: [PATCH 18/21] fix: let only plugins that may change the calendar or
tasks silence a reminder #1495
---
lib/__tests__/plugin-hook-permissions.test.ts | 33 ++++++++++++++++---
lib/plugin-hook-catalog.ts | 13 +++++++-
2 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/lib/__tests__/plugin-hook-permissions.test.ts b/lib/__tests__/plugin-hook-permissions.test.ts
index be4dd710a..550be16bb 100644
--- a/lib/__tests__/plugin-hook-permissions.test.ts
+++ b/lib/__tests__/plugin-hook-permissions.test.ts
@@ -152,25 +152,50 @@ describe('reminders', () => {
return (mocks.invokeHook as ReturnType).mock.calls;
}
- it.each(reminderHooks)('%s may be registered with calendar:read or tasks:read', (hook) => {
+ it.each(['onCalendarAlert', 'onCalendarAlertAcknowledge'])('%s may be registered with calendar:read or tasks:read', (hook) => {
expect(mayRegisterHook(plugin({ permissions: ['calendar:read'] }), hook)).toBe(true);
expect(mayRegisterHook(plugin({ permissions: ['tasks:read'] }), hook)).toBe(true);
expect(mayRegisterHook(plugin({ permissions: ['email:read'] }), hook)).toBe(false);
});
+ it('lets only a plugin that may change the calendar or the tasks take a reminder over', () => {
+ expect(mayRegisterHook(plugin({ permissions: ['calendar:read', 'tasks:read'] }), 'onBeforeCalendarAlert')).toBe(false);
+ expect(mayRegisterHook(plugin({ permissions: ['calendar:write'] }), 'onBeforeCalendarAlert')).toBe(true);
+ expect(mayRegisterHook(plugin({ permissions: ['tasks:write'] }), 'onBeforeCalendarAlert')).toBe(true);
+ });
+
it("hands a plugin with tasks:read a task's reminder only", async () => {
mocks.background.hooks = reminderHooks;
await loadSandboxedPlugin(plugin({ permissions: ['tasks:read'] }));
await calendarHooks.onCalendarAlert.emit(eventAlert);
- expect(await calendarHooks.onBeforeCalendarAlert.intercept(eventAlert)).toBe(true);
await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'a1', eventId: 'e1' });
expect(calls()).toHaveLength(0);
await calendarHooks.onCalendarAlert.emit(taskAlert);
- await calendarHooks.onBeforeCalendarAlert.intercept(taskAlert);
await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'follow-up', taskId: 't1' });
- expect(calls().map((c) => c[0])).toEqual(['onCalendarAlert', 'onBeforeCalendarAlert', 'onCalendarAlertAcknowledge']);
+ expect(calls().map((c) => c[0])).toEqual(['onCalendarAlert', 'onCalendarAlertAcknowledge']);
+ });
+
+ it("lets a plugin with tasks:write silence a task's reminder, never an event's", async () => {
+ mocks.background.hooks = ['onBeforeCalendarAlert'];
+ await loadSandboxedPlugin(plugin({ permissions: ['tasks:read', 'tasks:write', 'calendar:read'] }));
+ (mocks.invokeHook as ReturnType).mockResolvedValue(false);
+
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(eventAlert)).toBe(true);
+ expect(calls()).toHaveLength(0);
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(taskAlert)).toBe(false);
+ expect(calls().map((c) => c[0])).toEqual(['onBeforeCalendarAlert']);
+ });
+
+ it('does not let a plugin that only reads silence a reminder', async () => {
+ mocks.background.hooks = ['onBeforeCalendarAlert'];
+ await loadSandboxedPlugin(plugin({ permissions: ['calendar:read', 'tasks:read'] }));
+ (mocks.invokeHook as ReturnType).mockResolvedValue(false);
+
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(eventAlert)).toBe(true);
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(taskAlert)).toBe(true);
+ expect(calls()).toHaveLength(0);
});
it("hands a plugin with calendar:read an event's reminder only", async () => {
diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts
index a0715d9d7..c9cad78cf 100644
--- a/lib/plugin-hook-catalog.ts
+++ b/lib/plugin-hook-catalog.ts
@@ -56,6 +56,17 @@ const REMINDER = {
typeof payload === 'object' && payload !== null && 'taskId' in payload ? 'tasks:read' : 'calendar:read',
} as const satisfies Pick;
+/**
+ * Taking a reminder over silences Bulwark's own, so it needs the permission
+ * that could remove the alert anyway: calendar:write for an event's,
+ * tasks:write for a task's. Reading a calendar does not let a plugin mute it.
+ */
+const REMINDER_TAKEOVER = {
+ alsoPermission: 'tasks:write',
+ callPermission: (payload: unknown): Permission =>
+ typeof payload === 'object' && payload !== null && 'taskId' in payload ? 'tasks:write' : 'calendar:write',
+} as const satisfies Pick;
+
export const HOOK_CATALOG = {
// ─── Mail: message list ────────────────────────────────────
onEmailsFetched: H('mail-list', 'transform', 'email:render-takeover', 'Email[] (list rows), { mailboxId }', 'after a page of the message list is loaded, before it is shown'),
@@ -165,7 +176,7 @@ export const HOOK_CATALOG = {
onCalendarChange: H('calendar', 'observer', 'calendar:read', '{ calendarId, change: "created" | "updated" | "removed" }', 'after a calendar was created, changed or removed'),
onCalendarVisibilityToggle: H('calendar', 'observer', 'calendar:read', '{ calendarId, visible }', 'when a calendar is shown or hidden'),
onICalSubscriptionChange: H('calendar', 'observer', 'calendar:read', '{ subscriptionId, change: "added" | "updated" | "removed" | "refreshed" }', 'when a calendar subscription changes'),
- onBeforeCalendarAlert: { ...H('calendar', 'intercept', 'calendar:read', 'CalendarAlertView', "before Bulwark shows a reminder of an event or a task, in every open tab; return false to show it yourself (no toast, sound or title alert from Bulwark; onCalendarAlert still fires); a task's needs tasks:read, an event's calendar:read"), ...REMINDER },
+ onBeforeCalendarAlert: { ...H('calendar', 'intercept', 'calendar:write', 'CalendarAlertView', "before Bulwark shows a reminder of an event or a task, in every open tab; return false to show it yourself (no toast, sound or title alert from Bulwark; onCalendarAlert still fires); a task's needs tasks:write, an event's calendar:write"), ...REMINDER_TAKEOVER },
onCalendarAlert: { ...H('calendar', 'observer', 'calendar:read', 'CalendarAlertView ({ id, eventId, eventTitle, triggerTime } or { id, taskId, taskTitle, triggerTime })', "when a reminder of an event or a task fires, in every open tab, also one a plugin took over; a task's needs tasks:read, an event's calendar:read"), ...REMINDER },
onCalendarAlertAcknowledge: { ...H('calendar', 'observer', 'calendar:read', '{ alertId, eventId } or { alertId, taskId }', "when the user closes a reminder's toast, in that tab only; a task's needs tasks:read, an event's calendar:read"), ...REMINDER },
From 8ef3ad901ebe8274898ffc99cfb9ceef47a32458 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:18:49 +0100
Subject: [PATCH 19/21] fix: page large folder lists on stalwart 1.0 and retry
when mail arrives mid-load #1502
---
lib/__tests__/jmap-mailbox-pagination.test.ts | 56 ++++++++++++++++++-
lib/jmap/client.ts | 37 ++++++++++--
2 files changed, 84 insertions(+), 9 deletions(-)
diff --git a/lib/__tests__/jmap-mailbox-pagination.test.ts b/lib/__tests__/jmap-mailbox-pagination.test.ts
index 60cf0ea4c..4dea54e23 100644
--- a/lib/__tests__/jmap-mailbox-pagination.test.ts
+++ b/lib/__tests__/jmap-mailbox-pagination.test.ts
@@ -68,7 +68,6 @@ describe('JMAP mailbox pagination', () => {
['missing ids', undefined, 's', []],
['mixed ids', ['third', 4], 's', []],
['duplicate ids', ['parent'], 's', [{ id: 'parent', name: 'Parent' }]],
- ['changed state', ['third'], 's2', [{ id: 'third', name: 'Third' }]],
['early empty page', [], 's', []],
['missing object', ['third'], 's', []],
])('rejects %s instead of publishing a partial hierarchy', async (_label, ids, queryState, list) => {
@@ -143,16 +142,67 @@ describe('JMAP mailbox pagination', () => {
await expect(client.getAllMailboxesWithState()).rejects.toThrow('complete mailbox list');
});
- it('pages when an unbounded get is rejected as tooManyObjects', async () => {
+ it('pages when an unbounded get is rejected as requestTooLarge (Stalwart 1.0)', async () => {
const client = createClient();
const list = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }];
serve(client, { primary: list });
vi.mocked(client['request']).mockResolvedValueOnce({ methodResponses: [
- ['error', { type: 'tooManyObjects' }, '0'],
+ ['error', { type: 'requestTooLarge' }, '0'],
] });
expect((await client.getMailboxes()).map(({ id }) => id)).toEqual(['a', 'b', 'c']);
});
+ it('pages a shared account whose packed get is rejected as requestTooLarge', async () => {
+ const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
+ const shared = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B', parentId: 'a' }, { id: 'c', name: 'C', parentId: 'b' }];
+ serve(client, { primary: [{ id: 'inbox', name: 'Inbox' }], shared });
+ vi.mocked(client['request']).mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'inbox', name: 'Inbox' }], state: 'm' }, '0'],
+ ['error', { type: 'requestTooLarge' }, '1'],
+ ] });
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['inbox', 'shared:a', 'shared:b', 'shared:c']);
+ expect(result.states).toEqual({ primary: 'm', shared: 'm' });
+ });
+
+ it('starts over when the mailbox state moves between pages', async () => {
+ const client = createClient();
+ const page = (ids: string[], queryState: string, total?: number) => ({ methodResponses: [
+ ['Mailbox/query', { ids, queryState, ...(total ? { total } : {}) }, 'query'],
+ ['Mailbox/get', { list: ids.map((id) => ({ id, name: id })), state: queryState }, 'get'],
+ ] });
+ const request = vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [['Mailbox/get', { list: [{ id: 'a' }, { id: 'b' }] }, '0']] })
+ .mockResolvedValueOnce(page(['a', 'b'], 's1', 3))
+ .mockResolvedValueOnce(page(['c'], 's2'))
+ .mockResolvedValueOnce(page(['a', 'b'], 's2', 3))
+ .mockResolvedValueOnce(page(['c'], 's2'));
+
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['a', 'b', 'c']);
+ expect(result.states).toEqual({ primary: 's2' });
+ expect(request).toHaveBeenCalledTimes(5);
+ });
+
+ it('gives up when the mailbox state keeps moving', async () => {
+ const client = createClient();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ let n = 0;
+ const request = vi.spyOn(client as unknown as RequestTarget, 'request').mockImplementation(async (calls) => {
+ if (calls[0][0] === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: [{ id: 'a' }, { id: 'b' }] }, '0']] };
+ const position = calls[0][1].position as number;
+ const ids = position === 0 ? ['a', 'b'] : ['c'];
+ const state = `s${n++}`;
+ return { methodResponses: [
+ ['Mailbox/query', { ids, queryState: state, ...(position === 0 ? { total: 3 } : {}) }, 'query'],
+ ['Mailbox/get', { list: ids.map((id) => ({ id, name: id })), state }, 'get'],
+ ] };
+ });
+
+ await expect(client.getMailboxes()).rejects.toThrow('changed while it was being loaded');
+ expect(request).toHaveBeenCalledTimes(1 + 3 * 2);
+ });
+
it.each([false, true])('respects maxCallsInRequest=1 (shared: %s)', async (shared) => {
const client = createClient(2, shared ? { primary: { name: 'Self' }, shared: { name: 'Shared' } } : undefined);
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2, maxCallsInRequest: 1 } } });
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 1f02ee7df..a5168fc8a 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -603,6 +603,23 @@ const MAILBOX_PROPERTIES = [
"isSubscribed",
] as const;
+// A Mailbox/get without ids over maxObjectsInGet: Stalwart 0.16 cuts the list
+// at the limit without saying so, 1.0 answers requestTooLarge (RFC 8620 §5.1).
+function mailboxListCapped(response: JMAPResponse["methodResponses"][number] | undefined, maxObjects: number): boolean {
+ if (response?.[0] === "Mailbox/get") return Array.isArray(response[1].list) && response[1].list.length >= maxObjects;
+ return response?.[0] === "error" && response[1].type === "requestTooLarge";
+}
+
+// Every new message or flag change moves the Mailbox state (the counts), so a
+// paged load is retried a few times before the refresh gives up.
+const MAILBOX_TREE_ATTEMPTS = 3;
+
+class MailboxListChangedError extends Error {
+ constructor() {
+ super("Mailbox list changed while it was being loaded");
+ }
+}
+
// SieveScript properties (RFC 9661 §2), explicit for the same reason.
const SIEVE_SCRIPT_PROPERTIES = ["id", "name", "blobId", "isActive"] as const;
@@ -2277,6 +2294,16 @@ export class JMAPClient implements IJMAPClient {
* children without their parents. Querying all ids keeps the tree complete.
*/
private async fetchMailboxTree(accountId: string): Promise {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ return await this.fetchMailboxTreeOnce(accountId);
+ } catch (error) {
+ if (!(error instanceof MailboxListChangedError) || attempt >= MAILBOX_TREE_ATTEMPTS) throw error;
+ }
+ }
+ }
+
+ private async fetchMailboxTreeOnce(accountId: string): Promise {
const mailboxes: JMAPMailbox[] = [];
const seenIds = new Set();
let position = 0;
@@ -2335,10 +2362,10 @@ export class JMAPClient implements IJMAPClient {
}
if (queryState !== undefined && pageQueryState !== queryState) {
- throw new Error("Mailbox list changed while it was being loaded");
+ throw new MailboxListChangedError();
}
if (mailboxState !== undefined && pageMailboxState !== undefined && pageMailboxState !== mailboxState) {
- throw new Error("Mailbox list changed while it was being loaded");
+ throw new MailboxListChangedError();
}
queryState = pageQueryState;
if (typeof pageMailboxState === "string") mailboxState = pageMailboxState;
@@ -2367,8 +2394,7 @@ export class JMAPClient implements IJMAPClient {
];
let response = await this.request(calls, undefined, { skipOfflineRecord: true });
const first = response.methodResponses?.[0];
- if ((first?.[0] === "Mailbox/get" && first[1].list?.length >= this.getMaxObjectsInGet()) ||
- (first?.[0] === "error" && first[1].type === "tooManyObjects")) {
+ if (mailboxListCapped(first, this.getMaxObjectsInGet())) {
response = await this.fetchMailboxTree(acctId);
}
getOfflineHooks()?.record(this, calls, response);
@@ -2454,8 +2480,7 @@ export class JMAPClient implements IJMAPClient {
let match = response.methodResponses?.find((r) => r[2] === String(i));
// Keep the packed fast path for small accounts. A full-sized result
// may be capped, so reload that account through query/get pages.
- if ((match?.[0] === "Mailbox/get" && Array.isArray(match[1].list) && match[1].list.length >= maxObjects) ||
- (match?.[0] === "error" && match[1].type === "tooManyObjects")) {
+ if (mailboxListCapped(match, maxObjects)) {
try {
const complete = await this.fetchMailboxTree(accountId);
match = complete.methodResponses[0];
From 68dd11a30f0e75a32a77d3a424fce39bd273fdae Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:19:43 +0100
Subject: [PATCH 20/21] fix: show the built-in task list when a plugin's task
view does not draw #1498
---
components/calendar/calendar-app.tsx | 54 ++++++++++---------
.../__tests__/plugin-task-view.test.tsx | 33 +++++++++---
components/plugins/plugin-iframe-slot.tsx | 25 ++++++++-
components/plugins/plugin-task-view.tsx | 33 ++++++++----
4 files changed, 102 insertions(+), 43 deletions(-)
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 01df6c83b..9c7ad076a 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -1981,7 +1981,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onCreateEvent={() => openCreateModal()}
/>
);
- case "tasks":
+ case "tasks": {
+ const taskList = (
+ {
+ // Into a calendar that takes it: the default one, else the
+ // first the user may write to.
+ const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
+ if (!client || !target) {
+ toast.error(t("notifications.task_save_error"));
+ return;
+ }
+ taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
+ .then((created) => { if (created) toast.success(t("notifications.task_created")); })
+ .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
+ }}
+ />
+ );
return (
{taskStore.error === TASKS_LOAD_ERROR && (
@@ -1995,39 +2018,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
compact={isMobile}
/>
{/* A plugin may draw the list itself (the calendar-task-view
- slot); the toolbar above stays Bulwark's. */}
+ slot), with Bulwark's list back while its frame is not
+ drawn; the toolbar above stays Bulwark's. */}
{pluginTaskView ? (
- ) : (
- {
- // Into a calendar that takes it: the default one, else the
- // first the user may write to.
- const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
- if (!client || !target) {
- toast.error(t("notifications.task_save_error"));
- return;
- }
- taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
- .then((created) => { if (created) toast.success(t("notifications.task_created")); })
- .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
- }}
- />
- )}
+ ) : taskList}
);
+ }
}
})();
diff --git a/components/plugins/__tests__/plugin-task-view.test.tsx b/components/plugins/__tests__/plugin-task-view.test.tsx
index 69e59a383..fc5988e2f 100644
--- a/components/plugins/__tests__/plugin-task-view.test.tsx
+++ b/components/plugins/__tests__/plugin-task-view.test.tsx
@@ -2,7 +2,8 @@ import { cleanup, render, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
-const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
+const slotInstance = (initPromise: Promise = Promise.resolve({})) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn(), initPromise });
+const createSlotInstance = vi.fn((..._args: unknown[]) => slotInstance());
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
}));
@@ -11,7 +12,7 @@ vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}
const { PluginTaskView, usePluginTaskView } = await import('../plugin-task-view');
const registry = await import('@/lib/plugin-sandbox/registry');
-function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions'] } = {}) {
+function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions']; shouldShow?: boolean } = {}) {
const permissions = opts.permissions ?? ['ui:tasks', 'tasks:read'];
const plugin: InstalledPlugin = {
id, name: id, version: '1.0.0', author: '', description: '', type: 'ui-extension',
@@ -22,8 +23,8 @@ function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?:
plugin,
code: '',
tier: 'untrusted',
- background: { evaluateShouldShow: vi.fn(async () => true) } as never,
- slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }],
+ background: { evaluateShouldShow: vi.fn(async () => opts.shouldShow ?? true) } as never,
+ slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: opts.shouldShow !== undefined, ...(opts.fill ? { fill: true } : {}) }],
hookDisposables: [],
});
}
@@ -31,8 +32,9 @@ function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?:
/** The task view's list area as calendar-app draws it. */
function TaskArea({ calendarIds = ['c1', 'c2'] }: { calendarIds?: string[] }) {
const view = usePluginTaskView();
- if (!view) return ;
- return ;
+ const list = ;
+ if (!view) return list;
+ return ;
}
beforeEach(() => { createSlotInstance.mockClear(); });
@@ -64,6 +66,8 @@ describe('calendar-task-view', () => {
expect(opts.fill).toBe(true);
expect(opts.extraProps).toEqual({ filter: 'pending', calendarIds: ['c1', 'c2'] });
expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
+ await Promise.resolve();
+ expect(queryByTestId('bulwark-task-list')).toBeNull();
});
it('hands the calendars only to a plugin that reads tasks', async () => {
@@ -74,6 +78,23 @@ describe('calendar-task-view', () => {
expect(opts.extraProps).toEqual({ filter: 'pending' });
});
+ it("gives Bulwark's list back when the plugin's shouldShow says no", async () => {
+ offer('picky', { shouldShow: false });
+ const { findByTestId, container } = render();
+ expect(await findByTestId('bulwark-task-list')).toBeInTheDocument();
+ expect(createSlotInstance).not.toHaveBeenCalled();
+ expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden');
+ });
+
+ it("gives Bulwark's list back when the plugin's frame fails to start", async () => {
+ createSlotInstance.mockImplementationOnce(() => slotInstance(Promise.reject(new Error('boom'))));
+ offer('broken');
+ const { findByTestId, container } = render();
+ expect(await findByTestId('bulwark-task-list')).toBeInTheDocument();
+ expect(createSlotInstance).toHaveBeenCalledTimes(1);
+ expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden');
+ });
+
it('lets the area scroll for a plugin that does not fill it', async () => {
offer('plain');
const { container } = render();
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
index e02b9bcc9..1865ce624 100644
--- a/components/plugins/plugin-iframe-slot.tsx
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -25,6 +25,13 @@ interface Props {
extraProps?: Record;
/** The frame fills its panel and scrolls inside, instead of growing with its content. */
fill?: boolean;
+ /**
+ * Told whether the frame is up: false when the plugin's shouldShow says no
+ * or its frame fails to start, true once it has started. For a slot that
+ * stands in for a part of Bulwark (calendar-task-view), so the caller can
+ * draw its own part again.
+ */
+ onShownChange?: (shown: boolean) => void;
}
interface FloatingDropdownState {
@@ -38,9 +45,11 @@ interface FloatingDropdownState {
ariaLabel?: string;
}
-export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: Props) {
+export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, onShownChange }: Props) {
const wrapperRef = useRef(null);
const instanceRef = useRef(null);
+ const onShownChangeRef = useRef(onShownChange);
+ useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]);
const [height, setHeight] = useState(0);
const [width, setWidth] = useState(undefined);
const [floatingDropdown, setFloatingDropdown] = useState(null);
@@ -80,6 +89,10 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P
return () => { cancelled = true; };
}, [pluginId, slot, props]);
+ useEffect(() => {
+ if (show === false) onShownChangeRef.current?.(false);
+ }, [show]);
+
// Spawn / tear down the slot iframe.
useEffect(() => {
if (show !== true) return;
@@ -130,7 +143,17 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P
},
});
instanceRef.current = inst;
+ // A frame that fails to start stays blank; say so to a caller that can
+ // draw something in its place.
+ let live = true;
+ if (onShownChangeRef.current) {
+ inst.initPromise.then(
+ () => { if (live) onShownChangeRef.current?.(true); },
+ () => { if (live) onShownChangeRef.current?.(false); },
+ );
+ }
return () => {
+ live = false;
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
setFloatingDropdown(null);
diff --git a/components/plugins/plugin-task-view.tsx b/components/plugins/plugin-task-view.tsx
index 7684ac3a3..eb5fdd6bd 100644
--- a/components/plugins/plugin-task-view.tsx
+++ b/components/plugins/plugin-task-view.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useMemo } from 'react';
+import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import type { TaskViewFilter } from '@/stores/task-store';
import { cn } from '@/lib/utils';
@@ -9,7 +9,8 @@ import { PluginIframeSlot } from './plugin-iframe-slot';
// The `calendar-task-view` slot: a plugin drawing the calendar's task view
// below its toolbar, in place of Bulwark's task list. One plugin at a time,
// the first offering it (by its offer's order, then the plugins' order);
-// none, and the calendar shows its own list.
+// none, and the calendar shows its own list. So does a plugin whose
+// shouldShow says no or whose frame fails to start.
/** The plugin drawing the task view, or null when none offers it. */
export function usePluginTaskView(): { pluginId: string; fill: boolean } | null {
@@ -21,25 +22,35 @@ export function usePluginTaskView(): { pluginId: string; fill: boolean } | null
/**
* The plugin's frame in the area below the task toolbar: filled when the
* plugin asks for it (it scrolls inside), else the area scrolls. Its props:
- * the toolbar's filter and the calendars ticked in the sidebar.
+ * the toolbar's filter and the calendars ticked in the sidebar. `fallback`
+ * (Bulwark's list) stands in while the frame is not drawn.
*/
-export function PluginTaskView({ pluginId, fill, filter, calendarIds }: {
+export function PluginTaskView({ pluginId, fill, filter, calendarIds, fallback }: {
pluginId: string;
fill: boolean;
filter: TaskViewFilter;
calendarIds: readonly string[];
+ fallback: ReactNode;
}) {
// One array per set of ids, so the frame gets no update while they stay.
const idsKey = calendarIds.join('\u0000');
const ids = useMemo(() => (idsKey ? idsKey.split('\u0000') : []), [idsKey]);
const extraProps = useMemo(() => ({ filter, calendarIds: ids }), [filter, ids]);
+ // The plugin whose frame is not drawn. The frame stays mounted (hidden),
+ // so a shouldShow that says yes for another filter takes the view back.
+ const [notShownBy, setNotShownBy] = useState(null);
+ const notShown = notShownBy === pluginId;
+ const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]);
return (
-
-
-
+ <>
+ {notShown && fallback}
+
+
+
+ >
);
}
From e3fb3df5b032d1bf29d12e6bb29e05ead5112041 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:22:40 +0100
Subject: [PATCH 21/21] fix: say in the tasks permission that a plugin may
replace the task list #1498
---
locales/ar/common.json | 2 +-
locales/ca/common.json | 2 +-
locales/cs/common.json | 2 +-
locales/da/common.json | 2 +-
locales/de/common.json | 2 +-
locales/en/common.json | 2 +-
locales/es/common.json | 2 +-
locales/et/common.json | 2 +-
locales/fa/common.json | 2 +-
locales/fr/common.json | 2 +-
locales/he/common.json | 2 +-
locales/hu/common.json | 2 +-
locales/it/common.json | 2 +-
locales/ja/common.json | 2 +-
locales/ko/common.json | 2 +-
locales/lv/common.json | 2 +-
locales/mn/common.json | 2 +-
locales/nb/common.json | 2 +-
locales/nl/common.json | 2 +-
locales/pl/common.json | 2 +-
locales/pt/common.json | 2 +-
locales/ro/common.json | 2 +-
locales/ru/common.json | 2 +-
locales/sk/common.json | 2 +-
locales/th/common.json | 2 +-
locales/tr/common.json | 2 +-
locales/uk/common.json | 2 +-
locales/vi/common.json | 2 +-
locales/zh-TW/common.json | 2 +-
locales/zh/common.json | 2 +-
30 files changed, 30 insertions(+), 30 deletions(-)
diff --git a/locales/ar/common.json b/locales/ar/common.json
index b4e83bbc1..4834123a4 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "الإضافة إلى واجهة المهام",
- "body": "عرض أقسامها الخاصة في تفاصيل المهمة، وأزرار في قائمة المهام."
+ "body": "عرض أقسامها الخاصة في تفاصيل المهمة، وأزرار في قائمة المهام، وقائمة مهام خاصة بها بدلًا من قائمة Bulwark."
},
"ui_files": {
"title": "الإضافة إلى واجهة الملفات",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 0a2460822..d0c8d0403 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Afegir elements a la vista de tasques",
- "body": "Mostrar seccions pròpies als detalls d'una tasca i botons a la llista de tasques."
+ "body": "Mostrar seccions pròpies als detalls d'una tasca, botons a la llista de tasques i una llista de tasques pròpia en lloc de la de Bulwark."
},
"ui_files": {
"title": "Afegir elements a la vista de fitxers",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index cf460ede0..19c9c23a7 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Přidávat obsah do sekce Úkoly",
- "body": "Zobrazuje vlastní sekce v podrobnostech úkolu a tlačítka v seznamu úkolů."
+ "body": "Zobrazuje vlastní sekce v podrobnostech úkolu, tlačítka v seznamu úkolů a vlastní seznam úkolů místo seznamu Bulwarku."
},
"ui_files": {
"title": "Přidávat obsah do sekce Soubory",
diff --git a/locales/da/common.json b/locales/da/common.json
index 23696428d..e59b46fe5 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Tilføje til opgavevisningen",
- "body": "Vise sine egne sektioner i en opgaves detaljer og knapper i opgavelisten."
+ "body": "Vise sine egne sektioner i en opgaves detaljer, knapper i opgavelisten og sin egen opgaveliste i stedet for Bulwarks."
},
"ui_files": {
"title": "Tilføje til filvisningen",
diff --git a/locales/de/common.json b/locales/de/common.json
index f54369bf5..7d62d5117 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Aufgabenansicht erweitern",
- "body": "Eigene Abschnitte in den Details einer Aufgabe und Schaltflächen in der Aufgabenliste anzeigen."
+ "body": "Eigene Abschnitte in den Details einer Aufgabe, Schaltflächen in der Aufgabenliste und eine eigene Aufgabenliste anstelle der von Bulwark anzeigen."
},
"ui_files": {
"title": "Dateiansicht erweitern",
diff --git a/locales/en/common.json b/locales/en/common.json
index 2fea41def..68401f798 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6035,7 +6035,7 @@
},
"ui_tasks": {
"title": "Add to tasks",
- "body": "Show its own sections in a task's details and buttons in the task list."
+ "body": "Show its own sections in a task's details, buttons in the task list, and its own task list in place of Bulwark's."
},
"ui_files": {
"title": "Add to files",
diff --git a/locales/es/common.json b/locales/es/common.json
index 40572da71..1a1894e80 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Añadir a la vista de tareas",
- "body": "Mostrar sus propias secciones en los detalles de una tarea y botones en la lista de tareas."
+ "body": "Mostrar sus propias secciones en los detalles de una tarea, botones en la lista de tareas y su propia lista de tareas en lugar de la de Bulwark."
},
"ui_files": {
"title": "Añadir a la vista de archivos",
diff --git a/locales/et/common.json b/locales/et/common.json
index bfafac30f..6f4ccfaef 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6035,7 +6035,7 @@
},
"ui_tasks": {
"title": "Lisa ülesannetesse",
- "body": "Näidata oma jaotisi ülesande üksikasjades ja nuppe ülesannete nimekirjas."
+ "body": "Näidata oma jaotisi ülesande üksikasjades, nuppe ülesannete nimekirjas ja oma ülesannete nimekirja Bulwarki oma asemel."
},
"ui_files": {
"title": "Lisa failidesse",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 7318d61d9..5483d5468 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "افزودن به نمای وظایف",
- "body": "نمایش بخشهای خودش در جزئیات وظیفه و دکمهها در فهرست وظایف."
+ "body": "نمایش بخشهای خودش در جزئیات وظیفه، دکمهها در فهرست وظایف و فهرست وظایف خودش به جای فهرست Bulwark."
},
"ui_files": {
"title": "افزودن به نمای فایلها",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 4cbef611a..2facb918e 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Ajouter à l'affichage des tâches",
- "body": "Afficher ses propres sections dans les détails d'une tâche et des boutons dans la liste des tâches."
+ "body": "Afficher ses propres sections dans les détails d'une tâche, des boutons dans la liste des tâches et sa propre liste de tâches à la place de celle de Bulwark."
},
"ui_files": {
"title": "Ajouter à l'affichage des fichiers",
diff --git a/locales/he/common.json b/locales/he/common.json
index a0b3debc5..8e283e452 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6056,7 +6056,7 @@
},
"ui_tasks": {
"title": "הוספה לתצוגת המשימות",
- "body": "הצגת מקטעים משלו בפרטים של משימה וכפתורים ברשימת המשימות."
+ "body": "הצגת מקטעים משלו בפרטים של משימה, כפתורים ברשימת המשימות ורשימת משימות משלו במקום זו של Bulwark."
},
"ui_files": {
"title": "הוספה לתצוגת הקבצים",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index be3994d84..eadfecd9e 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Tartalom a feladatok nézetében",
- "body": "Saját szakaszokat jelenít meg egy feladat részleteiben és gombokat a feladatlistában."
+ "body": "Saját szakaszokat jelenít meg egy feladat részleteiben, gombokat a feladatlistában, és saját feladatlistát a Bulwark listája helyett."
},
"ui_files": {
"title": "Tartalom a fájlok nézetében",
diff --git a/locales/it/common.json b/locales/it/common.json
index 231cc1696..a37e6dfd9 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Aggiungere elementi alla vista attività",
- "body": "Mostrare le proprie sezioni nei dettagli di un'attività e pulsanti nell'elenco delle attività."
+ "body": "Mostrare le proprie sezioni nei dettagli di un'attività, pulsanti nell'elenco delle attività e un proprio elenco delle attività al posto di quello di Bulwark."
},
"ui_files": {
"title": "Aggiungere elementi alla vista file",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 3571796f2..65885f6e4 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "タスク画面に追加",
- "body": "タスクの詳細に独自のセクションを、タスク一覧にボタンを表示します。"
+ "body": "タスクの詳細に独自のセクションを、タスク一覧にボタンを表示し、Bulwark のタスク一覧の代わりに独自の一覧を表示します。"
},
"ui_files": {
"title": "ファイル画面に追加",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 079eac99f..04e0b18cd 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "할 일 화면에 추가",
- "body": "할 일 세부 정보에 자체 섹션을, 할 일 목록에 버튼을 표시해요."
+ "body": "할 일 세부 정보에 자체 섹션을, 할 일 목록에 버튼을 표시하고, Bulwark의 할 일 목록 대신 자체 목록을 표시해요."
},
"ui_files": {
"title": "파일 화면에 추가",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 64eeef5f5..5c5b50813 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Papildināt uzdevumu skatu",
- "body": "Rādīt savas sadaļas uzdevuma informācijā un pogas uzdevumu sarakstā."
+ "body": "Rādīt savas sadaļas uzdevuma informācijā, pogas uzdevumu sarakstā un savu uzdevumu sarakstu Bulwark saraksta vietā."
},
"ui_files": {
"title": "Papildināt failu skatu",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index e09096e2f..bf3f143cd 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Даалгаврын харагдацад нэмэх",
- "body": "Даалгаврын дэлгэрэнгүйд өөрийн хэсгүүдийг, даалгаврын жагсаалтад товчуудыг харуулах."
+ "body": "Даалгаврын дэлгэрэнгүйд өөрийн хэсгүүдийг, даалгаврын жагсаалтад товчуудыг, мөн Bulwark-ийн жагсаалтын оронд өөрийн даалгаврын жагсаалтыг харуулах."
},
"ui_files": {
"title": "Файлын харагдацад нэмэх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6218c62c6..f632107a3 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Legge til i oppgavevisningen",
- "body": "Vise egne seksjoner i detaljene for en oppgave og knapper i oppgavelisten."
+ "body": "Vise egne seksjoner i detaljene for en oppgave, knapper i oppgavelisten og en egen oppgaveliste i stedet for Bulwarks."
},
"ui_files": {
"title": "Legge til i filvisningen",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index d43ba15c7..9950dd801 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Toevoegen aan de takenweergave",
- "body": "Eigen secties tonen in de details van een taak en knoppen in de takenlijst."
+ "body": "Eigen secties tonen in de details van een taak, knoppen in de takenlijst en een eigen takenlijst in plaats van die van Bulwark."
},
"ui_files": {
"title": "Toevoegen aan de bestandenweergave",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 113232449..55743dc59 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Elementy w widoku zadań",
- "body": "Pokazuje własne sekcje w szczegółach zadania i przyciski na liście zadań."
+ "body": "Pokazuje własne sekcje w szczegółach zadania, przyciski na liście zadań i własną listę zadań zamiast listy Bulwarka."
},
"ui_files": {
"title": "Elementy w widoku plików",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a1bb94a19..ce46d7557 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Adicionar à visualização de tarefas",
- "body": "Mostrar as próprias seções nos detalhes de uma tarefa e botões na lista de tarefas."
+ "body": "Mostrar as próprias seções nos detalhes de uma tarefa, botões na lista de tarefas e a própria lista de tarefas no lugar da do Bulwark."
},
"ui_files": {
"title": "Adicionar à visualização de arquivos",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 326779d43..3354f9d47 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Adăugarea în vizualizarea sarcinilor",
- "body": "Afișează secțiuni proprii în detaliile unei sarcini și butoane în lista de sarcini."
+ "body": "Afișează secțiuni proprii în detaliile unei sarcini, butoane în lista de sarcini și o listă de sarcini proprie în locul celei din Bulwark."
},
"ui_files": {
"title": "Adăugarea în vizualizarea fișierelor",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index c6d3f6bce..191e6795c 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Добавлять элементы в интерфейс задач",
- "body": "Показывать свои разделы в сведениях о задаче и кнопки в списке задач."
+ "body": "Показывать свои разделы в сведениях о задаче, кнопки в списке задач и свой список задач вместо списка Bulwark."
},
"ui_files": {
"title": "Добавлять элементы в интерфейс файлов",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index bb5bb9044..7b6f3a858 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Pridávať obsah do sekcie Úlohy",
- "body": "Zobrazuje vlastné sekcie v podrobnostiach úlohy a tlačidlá v zozname úloh."
+ "body": "Zobrazuje vlastné sekcie v podrobnostiach úlohy, tlačidlá v zozname úloh a vlastný zoznam úloh namiesto zoznamu Bulwarku."
},
"ui_files": {
"title": "Pridávať obsah do sekcie Súbory",
diff --git a/locales/th/common.json b/locales/th/common.json
index e2a6bc31a..a267b8a17 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -2973,7 +2973,7 @@
},
"ui_tasks": {
"title": "เพิ่มองค์ประกอบในรายการงาน",
- "body": "แสดงส่วนข้อมูลของปลั๊กอินเองในรายละเอียดของงานและปุ่มในรายการงาน"
+ "body": "แสดงส่วนข้อมูลของปลั๊กอินเองในรายละเอียดของงาน ปุ่มในรายการงาน และรายการงานของปลั๊กอินเองแทนรายการของ Bulwark"
},
"ui_files": {
"title": "เพิ่มองค์ประกอบในไฟล์",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index d0b004a0b..fe50d565d 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Görevler görünümüne öğe ekleme",
- "body": "Bir görevin ayrıntılarında kendi bölümlerini, görev listesinde düğmeler gösterme."
+ "body": "Bir görevin ayrıntılarında kendi bölümlerini, görev listesinde düğmeleri ve Bulwark'ın listesi yerine kendi görev listesini gösterme."
},
"ui_files": {
"title": "Dosyalar görünümüne öğe ekleme",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 794ad0b85..b935ec6ac 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "Додавати елементи в інтерфейс завдань",
- "body": "Показувати власні розділи в подробицях завдання й кнопки в списку завдань."
+ "body": "Показувати власні розділи в подробицях завдання, кнопки в списку завдань і власний список завдань замість списку Bulwark."
},
"ui_files": {
"title": "Додавати елементи в інтерфейс файлів",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index b12aa9c13..3b4345708 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6047,7 +6047,7 @@
},
"ui_tasks": {
"title": "Thêm vào công việc",
- "body": "Hiển thị các phần riêng của nó trong chi tiết công việc và các nút trong danh sách công việc."
+ "body": "Hiển thị các phần riêng của nó trong chi tiết công việc, các nút trong danh sách công việc và danh sách công việc riêng thay cho danh sách của Bulwark."
},
"ui_files": {
"title": "Thêm vào tệp",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index e4b2c38be..fad0bcfce 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "新增到工作畫面",
- "body": "在工作詳細資料中顯示它自己的區塊,並在工作清單中顯示按鈕。"
+ "body": "在工作詳細資料中顯示它自己的區塊,在工作清單中顯示按鈕,並可用它自己的工作清單取代 Bulwark 的工作清單。"
},
"ui_files": {
"title": "新增到檔案畫面",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 012aa038d..f5b2466aa 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6055,7 +6055,7 @@
},
"ui_tasks": {
"title": "添加到任务界面",
- "body": "在任务详情中显示它自己的区块,并在任务列表中显示按钮。"
+ "body": "在任务详情中显示它自己的区块,在任务列表中显示按钮,并可用它自己的任务列表代替 Bulwark 的任务列表。"
},
"ui_files": {
"title": "添加到文件界面",