This commit is contained in:
Linus Rath
2026-11-02 21:11:48 +01:00
96 changed files with 3638 additions and 267 deletions
+50 -22
View File
@@ -32,6 +32,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";
@@ -761,6 +763,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.
@@ -1767,6 +1778,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),
@@ -1985,7 +1998,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onCreateEvent={() => openCreateModal()}
/>
);
case "tasks":
case "tasks": {
const taskList = (
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
// 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 (
<div className="flex flex-col h-full">
{taskStore.error === TASKS_LOAD_ERROR && (
@@ -1998,29 +2034,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
taskIds={listedTaskIds}
compact={isMobile}
/>
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
// 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), with Bulwark's list back while its frame is not
drawn; the toolbar above stays Bulwark's. */}
{pluginTaskView ? (
<PluginTaskView
pluginId={pluginTaskView.pluginId}
fill={pluginTaskView.fill}
filter={taskStore.filter}
calendarIds={selectedCalendarIds}
fallback={taskList}
/>
) : taskList}
</div>
);
}
}
})();
@@ -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,
@@ -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: () => {},
@@ -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: () => {},
@@ -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(<EmailComposer initialData={{ ...DRAFT, body: 'Draft text' }} onSend={onSend} />);
// 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(<EmailComposer mode="reply" initialData={{ ...DRAFT, mode: 'reply' }} onSend={onSend} />);
// 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');
});
});
@@ -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: () => {},
@@ -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: () => {},
@@ -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: () => {},
@@ -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: '<p>Your apartment</p><p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Floor plan"></p>',
isHTML: true,
isFavorite: false,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
// The picker hands the composer a template, as choosing one does. It takes
// the focus away from the editor first, as the real dialog does.
vi.mock('@/components/templates/template-picker', () => ({
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
}));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<p>Cheers, Me</p>' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
recipientMentionsEnabled: false,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
externalContentPolicy: 'block',
isSenderTrusted: () => false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const DRAFT = {
to: 'guest@example.com',
cc: '',
bcc: '',
subject: '',
body: '<p></p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
describe('applying a template with a data: image (#1490)', () => {
const range = Range.prototype as unknown as Record<string, unknown>;
const doc = document as unknown as Record<string, unknown>;
const client = {
uploadBlob: vi.fn(async (_file: File) => ({ blobId: 'blob-plan' })),
hasDelayedSend: () => false,
getMaxDelayedSend: () => 0,
};
beforeAll(() => {
range.getClientRects = () => [];
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
doc.elementFromPoint = () => null;
useAuthStore.setState({ client: client as never });
});
afterAll(() => {
delete range.getClientRects;
delete range.getBoundingClientRect;
delete doc.elementFromPoint;
useAuthStore.setState({ client: null });
});
afterEach(() => {
vi.clearAllMocks();
});
it('uploads the image and sends it as an inline part shown by cid', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT} onSend={onSend} onClose={() => {}} />);
await waitFor(() => {
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
expect(el?.editor).toBeTruthy();
});
await nextFrame();
act(() => { fireEvent.keyDown(window, { key: 't' }); });
fireEvent.click(await screen.findByTestId('pick-template'));
await waitFor(() => expect(screen.queryByTestId('pick-template')).toBeNull());
await waitFor(() => expect(document.querySelector('.ProseMirror img')).not.toBeNull());
expect(client.uploadBlob).toHaveBeenCalledTimes(1);
const file = client.uploadBlob.mock.calls[0][0];
expect(file.type).toBe('image/png');
expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from(atob(PNG), (c) => c.charCodeAt(0)));
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const { htmlBody, attachments } = onSend.mock.calls[0][0];
const inline = attachments?.find((a: { disposition?: string }) => a.disposition === 'inline');
expect(inline).toMatchObject({ blobId: 'blob-plan', type: 'image/png' });
expect(htmlBody).toContain(`src="cid:${inline.cid}"`);
expect(htmlBody).not.toContain('data:image');
});
});
@@ -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',
+65 -45
View File
@@ -103,7 +103,7 @@ import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview"
import "@/lib/org/outgoing";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent, embedTemplateImages } from "@/lib/template-utils";
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
import enMessages from "@/locales/en/common.json";
import { TemplatePicker } from "@/components/templates/template-picker";
@@ -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 =>
@@ -1320,6 +1328,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
@@ -1361,7 +1372,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);
@@ -2106,6 +2117,40 @@ export function EmailComposer({
}
};
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const [{ blobId }, dataUrl] = await Promise.all([
(composerClientRef.current ?? client).uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
uploadedAt: Date.now(),
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record<string, string>) => {
let filledSubject = Object.keys(filledValues).length > 0
? substitutePlaceholders(template.subject, filledValues)
@@ -2128,6 +2173,8 @@ export function EmailComposer({
filledSubject = rendered.subject;
bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html);
}
// Its data: images go out as cid: parts, like pasted ones (#1490).
if (!plainTextMode) bodyContent = await embedTemplateImages(bodyContent, handleImageUpload);
if (mode === 'compose') {
// An empty template subject must not wipe one the user typed (#540).
@@ -2213,7 +2260,7 @@ export function EmailComposer({
setShowTemplatePicker(false);
notifyTemplateApplied(template);
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]);
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled, handleImageUpload]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
@@ -2396,40 +2443,6 @@ export function EmailComposer({
}
}, [client, t, plainTextMode]);
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const [{ blobId }, dataUrl] = await Promise.all([
(composerClientRef.current ?? client).uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
uploadedAt: Date.now(),
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
await addFiles(Array.from(event.target.files));
@@ -2661,7 +2674,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,
@@ -2669,7 +2682,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);
@@ -3606,9 +3619,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) ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
@@ -3625,8 +3643,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.
@@ -4778,8 +4796,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) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
+40 -5
View File
@@ -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<MvSidePanelId | null>(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" && <ContactsPanel onClose={() => setOpen(null)} />}
</aside>
)}
<nav
{pluginOpen && (
<aside
data-simple-hide=""
data-plugin-side-panel={pluginOpen.pluginId}
className="mb-4 me-2 flex w-[300px] shrink-0 flex-col overflow-hidden rounded-2xl bg-background"
aria-label={pluginOpen.title}
>
<PluginSidePanelBody entry={pluginOpen} focusRequest={plugins.focusRequest} onClose={() => plugins.choose(null)} />
</aside>
)}
{(buttons.length > 0 || pluginButtons.length > 0) && <nav
data-mv-side-rail=""
className="flex w-14 shrink-0 flex-col items-center gap-2 pt-3"
aria-label={t("mv_side_panel")}
@@ -84,7 +109,7 @@ export function MvSidePanel() {
<button
key={id}
type="button"
onClick={() => setOpen((cur) => (cur === id ? null : id))}
onClick={() => openApp(active === id ? null : id)}
className={cn(
"relative grid h-10 w-10 place-items-center rounded-full transition-colors hover:bg-[var(--mv-nav-hover)]",
active === id && "bg-[var(--mv-nav-selected)]",
@@ -96,8 +121,18 @@ export function MvSidePanel() {
<Icon className={cn("h-5 w-5", color)} />
</button>
))}
{pluginButtons.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={pluginOpen?.pluginId === entry.pluginId}
onClick={() => openPlugin(pluginOpen?.pluginId === entry.pluginId ? null : entry.pluginId)}
className="h-10 w-10 rounded-full hover:bg-[var(--mv-nav-hover)]"
pressedClassName="bg-[var(--mv-nav-selected)] text-foreground"
/>
))}
<div className="my-1 h-px w-5 bg-border" aria-hidden />
</nav>
</nav>}
</>
);
}
@@ -256,3 +256,41 @@ 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(<MailApp linkSegments={['message', 'eaaaaab']} />);
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, '', '/');
}
});
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(<MailApp linkSegments={['folder', 'inbox']} />);
try {
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
} finally {
mounted.unmount();
window.history.replaceState(null, '', '/');
}
});
});
+12 -1
View File
@@ -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";
@@ -1809,7 +1810,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;
@@ -4136,6 +4141,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,
@@ -5785,6 +5792,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 && <MvSidePanel />}
{/* 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 && <PluginSidePanel />}
</div>
{/* Keyboard Shortcuts Modal */}
@@ -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<string, string>) => (values?.name ? `${key}:${values.name}` : key) }));
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => <div data-testid="custom-frame" /> }));
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(<PluginDialogHost />);
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<HTMLElement>('[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(<PluginDialogHost />);
let result: Promise<unknown>;
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(<PluginDialogHost />);
let result: Promise<unknown>;
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(<PluginDialogHost />);
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();
});
});
@@ -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(<PluginSidePanel />);
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(<PluginSidePanel />);
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<string, unknown>; 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(<PluginSidePanel />);
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
first.unmount();
const { container } = render(<PluginSidePanel />);
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(<PluginSidePanel />);
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(<PluginSidePanel />);
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(<PluginSidePanel />);
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(<MvSidePanel />);
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(<MvSidePanel />);
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(<MvSidePanel />);
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
});
});
@@ -0,0 +1,104 @@
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 slotInstance = (initPromise: Promise<unknown> = 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),
}));
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']; 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',
permissions, grantedPermissions: permissions, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin,
code: '',
tier: 'untrusted',
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: [],
});
}
/** The task view's list area as calendar-app draws it. */
function TaskArea({ calendarIds = ['c1', 'c2'] }: { calendarIds?: string[] }) {
const view = usePluginTaskView();
const list = <div data-testid="bulwark-task-list" />;
if (!view) return list;
return <PluginTaskView pluginId={view.pluginId} fill={view.fill} filter="pending" calendarIds={calendarIds} fallback={list} />;
}
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(<TaskArea />);
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(<TaskArea />);
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<string, unknown>; 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();
await Promise.resolve();
expect(queryByTestId('bulwark-task-list')).toBeNull();
});
it('hands the calendars only to a plugin that reads tasks', async () => {
offer('looks', { permissions: ['ui:tasks'] });
render(<TaskArea />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record<string, unknown> };
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(<TaskArea />);
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(<TaskArea />);
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(<TaskArea />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto');
});
});
+117 -28
View File
@@ -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: '
</p>
) : null;
const footer = buttons.length > 0 && (
<div className={cn('flex flex-wrap items-center justify-end gap-2 border-t border-border', anchor ? 'px-3 py-2' : 'px-6 py-4')} data-plugin-dialog-buttons="">
{buttons.map((b) => (
<Button
key={b.id}
type="button"
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
size={anchor ? 'sm' : undefined}
disabled={b.disabled || b.busy}
aria-busy={b.busy || undefined}
className="gap-1.5"
onClick={() => pressButton(b.id)}
>
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
</div>
);
const frame = (
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
/>
);
// 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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-hidden={layer === 'below' ? true : undefined}
className="fixed inset-0 z-[100000]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
data-plugin-dialog-popover=""
>
<div
ref={panelRef}
className="absolute flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in fade-in zoom-in-95 duration-150"
style={{
left: place?.left ?? 0,
top: place?.top ?? 0,
visibility: place ? undefined : 'hidden',
width: `min(${current.width ?? 560}px, calc(100vw - ${2 * POPOVER_MARGIN}px))`,
maxHeight: `calc(100vh - ${2 * POPOVER_MARGIN}px)`,
}}
data-plugin-dialog={current.pluginId}
>
<div className="flex items-center gap-1 border-b border-border ps-3 pe-1 py-0.5">
<h2 id={titleId} className="sr-only">{current.title || name}</h2>
<p className="flex min-w-0 flex-1 items-center gap-1 text-xs text-muted-foreground" data-plugin-dialog-source="">
<Puzzle className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">{t('from_plugin', { name })}</span>
</p>
<PluginDialogMenu name={name} onTurnOff={turnOff} compact />
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{frame}</div>
{footer}
</div>
</div>
);
}
return (
<div
role="dialog"
@@ -242,30 +321,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{message}
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
/>
{frame}
</div>
{buttons.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2 px-6 py-4 border-t border-border" data-plugin-dialog-buttons="">
{buttons.map((b) => (
<Button
key={b.id}
type="button"
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
disabled={b.disabled || b.busy}
aria-busy={b.busy || undefined}
className="gap-1.5"
onClick={() => pressButton(b.id)}
>
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
</div>
)}
{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<HTMLElement | null>): { 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<HTMLDivElement>(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')}
>
<MoreVertical className="w-5 h-5" />
<MoreVertical className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
</button>
{open && (
<div role="menu" className="absolute end-0 top-full z-10 mt-1 min-w-[200px] rounded-md border border-border bg-background py-1 shadow-lg">
+24 -1
View File
@@ -25,6 +25,13 @@ interface Props {
extraProps?: Record<string, unknown>;
/** 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<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const onShownChangeRef = useRef(onShownChange);
useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]);
const [height, setHeight] = useState<number>(0);
const [width, setWidth] = useState<number | undefined>(undefined);
const [floatingDropdown, setFloatingDropdown] = useState<FloatingDropdownState | null>(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);
+193
View File
@@ -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<string | null>(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 (
<>
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
</div>
<div
data-plugin-slot="side-panel"
className={cn('min-h-0 flex-1', entry.fill ? 'flex flex-col' : 'overflow-y-auto')}
>
<PluginIframeSlot key={entry.pluginId} pluginId={entry.pluginId} slot="side-panel" extraProps={extraProps} fill={entry.fill} />
</div>
</>
);
}
/**
* 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 && (
<aside
data-plugin-side-panel={active.pluginId}
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
aria-label={active.title}
>
<PluginSidePanelBody entry={active} focusRequest={focusRequest} onClose={() => choose(null)} />
</aside>
)}
{inRail.length > 0 && <nav
data-plugin-side-rail=""
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
aria-label={t('mv_side_panel')}
>
{inRail.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={open === entry.pluginId}
onClick={() => choose(open === entry.pluginId ? null : entry.pluginId)}
className="h-9 w-9 rounded-md hover:bg-muted"
pressedClassName="bg-muted text-foreground"
/>
))}
</nav>}
</>
);
}
/** 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 (
<button
type="button"
onClick={onClick}
data-plugin-side-panel-button={entry.pluginId}
className={cn('grid place-items-center text-muted-foreground transition-colors', className, pressed && pressedClassName)}
title={entry.title}
aria-label={entry.title}
aria-pressed={pressed}
>
<Icon className="h-5 w-5" />
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
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';
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. 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 {
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. `fallback`
* (Bulwark's list) stands in while the frame is not drawn.
*/
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<string | null>(null);
const notShown = notShownBy === pluginId;
const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]);
return (
<>
{notShown && fallback}
<div
data-plugin-slot="calendar-task-view"
data-plugin-task-view={pluginId}
className={cn('min-h-0 flex-1', fill ? 'flex flex-col' : 'overflow-y-auto', notShown && 'hidden')}
>
<PluginIframeSlot key={pluginId} pluginId={pluginId} slot="calendar-task-view" extraProps={extraProps} fill={fill} onShownChange={onShownChange} />
</div>
</>
);
}
@@ -36,6 +36,8 @@ export function ComposingSettings() {
mentionAddsRecipientTo,
sendDelaySeconds,
signaturePosition,
signatureNewMailEnabled,
signatureReplyForwardEnabled,
signatureSeparatorEnabled,
requestReadReceiptDefault,
readReceiptResponse,
@@ -124,6 +126,20 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('signature_new_mail.label')} description={t('signature_new_mail.description')}>
<ToggleSwitch
checked={signatureNewMailEnabled}
onChange={(checked) => updateSetting('signatureNewMailEnabled', checked)}
/>
</SettingItem>
<SettingItem label={t('signature_reply_forward.label')} description={t('signature_reply_forward.description')}>
<ToggleSwitch
checked={signatureReplyForwardEnabled}
onChange={(checked) => updateSetting('signatureReplyForwardEnabled', checked)}
/>
</SettingItem>
<SettingItem advanced label={t('signature_separator.label')} description={t('signature_separator.description')}>
<ToggleSwitch
checked={signatureSeparatorEnabled}
@@ -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(<Toasts />);
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(<Toasts />);
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(<Toasts />);
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(<Toasts />);
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();
+62 -36
View File
@@ -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 });
},
});
});
}
}
+75
View File
@@ -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();
}
});
}
});
+61
View File
@@ -0,0 +1,61 @@
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<unknown>,
options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {},
): Promise<string> {
const source = readFileSync(resolve('public/sw.js'), 'utf8');
let notification: { data: unknown } | undefined;
let target: string | undefined;
const listeners = new Map<string, (event: unknown) => void>();
const cache = new Map<string, string>();
const client = {
id: 'window-1', url: 'https://mail.example.test/',
postMessage() {},
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: 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),
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<string, unknown>) {
const waits: Promise<unknown>[] = [];
const listener = listeners.get(type);
if (!listener) throw new Error(`Missing worker handler: ${type}`);
listener({ ...event, waitUntil: (p: Promise<unknown>) => 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;
}
@@ -0,0 +1,230 @@
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, unknown>, string];
type RequestTarget = { request(calls: MethodCall[]): Promise<unknown> };
function createClient(
maxObjectsInGet = 2,
accounts: Record<string, { name: string }> = { 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<string, RawMailbox[]>, 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' }]],
['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('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')
.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'));
await expect(client.getAllMailboxesWithState()).rejects.toThrow('complete mailbox list');
});
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: '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 } } });
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']]);
}
});
});
@@ -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', () => {
+136
View File
@@ -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<HTMLElement>('[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('<button data-at><span>Follow up</span></button>', { 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('<div role="menu"><div role="menuitem" data-at>Follow up</div></div>', { 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('<button data-at>Flag</button>', { 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('<button data-at>Flag</button>', { 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('<button data-at>Flag</button>', { 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('<button data-at>Follow up</button>', { 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('<button data-at>Follow up</button>', { 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;
});
});
+69 -1
View File
@@ -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,74 @@ 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<typeof vi.fn>).mock.calls;
}
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);
await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'a1', eventId: 'e1' });
expect(calls()).toHaveLength(0);
await calendarHooks.onCalendarAlert.emit(taskAlert);
await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'follow-up', taskId: 't1' });
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<typeof vi.fn>).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<typeof vi.fn>).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 () => {
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();
+19
View File
@@ -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: '<a href="https://x/y">y</a>', text: 'https://x/y' };
@@ -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,105 @@ 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);
// 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'],
}) 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', () => {
@@ -36,7 +36,10 @@ function gatedPermissions(): Map<string, string[]> {
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;
+6
View File
@@ -77,6 +77,12 @@ describe('settings search index (English messages)', () => {
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');
+62
View File
@@ -0,0 +1,62 @@
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('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();
});
it('keeps old unscoped folder links working', () => {
expect(resolveFolderRef('inbox', folders)).toBe('a');
});
});
+49
View File
@@ -12,6 +12,7 @@ import {
importTemplates,
spliceTemplateAboveSignature,
composeBodyHasUserContent,
embedTemplateImages,
} from '../template-utils';
import type { EmailTemplate } from '../template-types';
@@ -437,3 +438,51 @@ describe('composeBodyHasUserContent', () => {
expect(composeBodyHasUserContent(`<p>Hello Mr.</p>${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(`<p>Plan</p><p><img src="${DATA}" alt="Floor plan"></p>`, 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(`<img src="${DATA}"><img src="${DATA}">`, 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(`<img src="${DATA}" data-cid="old@webmail">`, 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 = '<img src="https://example.com/a.png"><img src="cid:a@b"><img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">';
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 = `<img src="${DATA}">`;
expect(await embedTemplateImages(html, async () => null)).toBe(html);
});
});
+12 -3
View File
@@ -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 };
}
}
+154 -37
View File
@@ -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;
@@ -2159,11 +2176,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<JMAPResponse> {
private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean; skipOfflineRecord?: boolean }): Promise<JMAPResponse> {
if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.');
}
@@ -2193,7 +2212,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 +2289,121 @@ 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<JMAPResponse> {
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<JMAPResponse> {
const mailboxes: JMAPMailbox[] = [];
const seenIds = new Set<string>();
let position = 0;
let total: number | undefined;
let queryState: string | undefined;
let mailboxState: string | undefined;
let hasMorePages = true;
while (hasMorePages) {
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"],
] : [queryCall], undefined, { skipOfflineRecord: true });
const queryResponse = response.methodResponses?.[0];
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;
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 MailboxListChangedError();
}
if (mailboxState !== undefined && pageMailboxState !== undefined && pageMailboxState !== mailboxState) {
throw new MailboxListChangedError();
}
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<Mailbox[]> {
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 (mailboxListCapped(first, this.getMaxObjectsInGet())) {
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,
@@ -2354,6 +2457,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
@@ -2370,14 +2474,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 (mailboxListCapped(match, maxObjects)) {
try {
const complete = await this.fetchMailboxTree(accountId);
match = complete.methodResponses[0];
} catch (error) {
fetchFailed = true;
paginationFailed = 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 +2507,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);
@@ -2414,6 +2527,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);
+93 -1
View File
@@ -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 {
+19 -1
View File
@@ -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<string, unknown>, nam
};
}
/** Unfiltered mailbox pages used to load a complete folder list offline. */
async function mailboxQuery(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
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<string, unknown>[];
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<string, unknown>): 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;
@@ -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) => {
+12 -2
View File
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const html = signatureIdentity?.htmlSignature?.trim()
const html = insertSignature && signatureIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, signatureIdentity, { separator })
: undefined;
+29 -2
View File
@@ -36,11 +36,37 @@ 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<HookSpec, 'alsoPermission' | 'callPermission'>;
/**
* 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<HookSpec, 'alsoPermission' | 'callPermission'>;
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 +176,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: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 },
// ─── Contacts ──────────────────────────────────────────────
onContactOpen: H('contacts', 'observer', 'contacts:read', 'ContactView', 'when a contact is opened'),
+12 -2
View File
@@ -171,10 +171,13 @@ export class HookBus<T extends (...args: any[]) => 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<T>[]): void {
@@ -262,7 +265,8 @@ export class HookBus<T extends (...args: any[]) => 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.
+55
View File
@@ -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/<view>) 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<Record<CalendarViewMode, readonly Permission[]>> = {
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 }));
}
+125
View File
@@ -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 };
}
+80 -6
View File
@@ -6,18 +6,29 @@
// 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, 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 +232,39 @@ async function pickCalendar(
return choice.id;
}
export async function doCalendarListCalendars(): Promise<CalendarView[]> {
async function birthdayCalendarName(): Promise<string> {
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<ContactCard[]> {
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<CalendarView[]> {
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 +274,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<CalendarEventView[]> {
export async function doCalendarGetEvents(options: unknown, plugin?: InstalledPlugin): Promise<CalendarEventView[]> {
const method = 'calendar.getEvents';
const opts = readObject(options, method, 'options', ['from', 'to', 'calendarIds']);
const from = readWhen(opts.from, method, 'from');
@@ -260,6 +301,24 @@ export async function doCalendarGetEvents(options: unknown): Promise<CalendarEve
const valid = raw.filter((e) => 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);
// 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);
}
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 +353,21 @@ async function loadEvent(client: IJMAPClient, id: string): Promise<CalendarEvent
};
}
export async function doCalendarGetEvent(id: unknown): Promise<CalendarEventView | null> {
export async function doCalendarGetEvent(id: unknown, plugin?: InstalledPlugin): Promise<CalendarEventView | null> {
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 +378,9 @@ export async function doCalendarGetEvent(id: unknown): Promise<CalendarEventView
* range the calendar has loaded is added to that list first.
*/
async function eventForMutation(client: IJMAPClient, method: string, id: string): Promise<CalendarEvent> {
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)) {
+22 -4
View File
@@ -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';
@@ -67,8 +68,11 @@ 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 { 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,9 +258,17 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'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',
// 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,
@@ -2035,8 +2047,11 @@ 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.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)}"`);
@@ -2192,7 +2207,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<string, unknown>; width?: number };
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number; anchor?: unknown };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
@@ -2203,6 +2218,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': {
@@ -2307,9 +2325,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]);
+8
View File
@@ -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<string, unknown>;
/** 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;
}
+11 -3
View File
@@ -24,12 +24,13 @@ 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';
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';
@@ -297,9 +298,15 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
// 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;
}
@@ -390,6 +397,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 */ }
+52
View File
@@ -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;
}
+10 -1
View File
@@ -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;
+5 -5
View File
@@ -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';
+14 -2
View File
@@ -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 ────────────────────────────────────────────────────
+44 -5
View File
@@ -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<string, unknown>; 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<string, unknown>; width?: number; anchor?: 'trigger' }) =>
callApi('ui.openDialog', [opts], 0) as Promise<unknown>,
/** 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. */
@@ -741,6 +751,24 @@ function buildPluginApi(manifest: PluginManifest) {
*/
openPage: (pageId: string, path?: string) =>
callApi('ui.openPage', [pageId, path]) as Promise<boolean>,
/**
* 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<boolean>,
/**
* 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<boolean>,
/**
* 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
@@ -758,12 +786,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<void>,
/**
* 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<void>,
/**
* 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.
*/
+72
View File
@@ -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<string, SidePanelLook> = 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<string, unknown>;
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<string, SidePanelLook> {
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); };
}
+1 -1
View File
@@ -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', 'calendar-task-view'];
export function isFillableSlot(slot: string): boolean {
return FILLABLE_SLOTS.includes(slot);
+3 -1
View File
@@ -44,13 +44,14 @@ 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 ────────────────────────────────────────────
'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'),
@@ -106,6 +107,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'),
+14 -6
View File
@@ -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;
+2
View File
@@ -247,6 +247,8 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ 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' },
+48
View File
@@ -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<string> {
if (!/<img\b[^>]*\bsrc\s*=\s*["']?\s*data:image\//i.test(html)) return html;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, '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);
}
+9 -1
View File
@@ -1952,6 +1952,14 @@
"above_quote": "قبل النص المقتبس",
"below_quote": "بعد النص المقتبس"
},
"signature_new_mail": {
"label": "التوقيع في الرسائل الجديدة",
"description": "يُدرج توقيعك تلقائيًا عند كتابة رسالة جديدة. عطّله لبدء كل رسالة جديدة دون توقيع."
},
"signature_reply_forward": {
"label": "التوقيع في الردود وإعادة التوجيه",
"description": "يُرفق توقيعك تلقائيًا عند الرد على رسالة أو إعادة توجيهها. عطّله لإرسالها دون توقيع."
},
"signature_separator": {
"label": "فاصل التوقيع",
"description": "إضافة السطر الفاصل القياسي \"-- \" قبل التوقيع (RFC 3676). عطّله إذا كنت تفضّل الانتقال مباشرة من رسالتك إلى التوقيع."
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "الإضافة إلى واجهة المهام",
"body": "عرض أقسامها الخاصة في تفاصيل المهمة، وأزرار في قائمة المهام."
"body": "عرض أقسامها الخاصة في تفاصيل المهمة، وأزرار في قائمة المهام، وقائمة مهام خاصة بها بدلًا من قائمة Bulwark."
},
"ui_files": {
"title": "الإضافة إلى واجهة الملفات",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6035,7 +6043,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6035,7 +6043,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",
+9 -1
View File
@@ -1952,6 +1952,14 @@
"above_quote": "قبل از نقل قول",
"below_quote": "بعد از نقل قول"
},
"signature_new_mail": {
"label": "امضا در نامه‌های جدید",
"description": "هنگام نوشتن نامه جدید، امضای شما به‌طور خودکار درج می‌شود. برای شروع هر نامه جدید بدون امضا، خاموش کنید."
},
"signature_reply_forward": {
"label": "امضا در پاسخ و ارجاع",
"description": "هنگام پاسخ دادن یا ارجاع دادن نامه، امضای شما به‌طور خودکار افزوده می‌شود. برای ارسال بدون امضا، خاموش کنید."
},
"signature_separator": {
"label": "جداکننده امضا",
"description": "پیشوند \"-- \""
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "افزودن به نمای وظایف",
"body": "نمایش بخش‌های خودش در جزئیات وظیفه و دکمه‌ها در فهرست وظایف."
"body": "نمایش بخش‌های خودش در جزئیات وظیفه، دکمه‌ها در فهرست وظایف و فهرست وظایف خودش به جای فهرست Bulwark."
},
"ui_files": {
"title": "افزودن به نمای فایل‌ها",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -1996,6 +1996,14 @@
"above_quote": "לפני טקסט מצוטט",
"below_quote": "אחרי טקסט מצוטט"
},
"signature_new_mail": {
"label": "חתימה בהודעות חדשות",
"description": "מוסיף אוטומטית את החתימה שלך בעת כתיבת הודעה חדשה. כבה כדי להתחיל כל הודעה חדשה בלי חתימה."
},
"signature_reply_forward": {
"label": "חתימה בתשובות והעברות",
"description": "מצרף אוטומטית את החתימה שלך בעת מענה או העברה של הודעה. כבה כדי לשלוח אותן בלי חתימה."
},
"signature_separator": {
"label": "מפריד חתימה",
"description": "הוסף קידומת לחתימה בעדכון סטנדרטי ״-- ״ (RFC 3676). כבה אם אתה מעדיף לזרום ישירות מההודעה שלך לחתימה."
@@ -6056,7 +6064,7 @@
},
"ui_tasks": {
"title": "הוספה לתצוגת המשימות",
"body": "הצגת מקטעים משלו בפרטים של משימה וכפתורים ברשימת המשימות."
"body": "הצגת מקטעים משלו בפרטים של משימה, כפתורים ברשימת המשימות ורשימת משימות משלו במקום זו של Bulwark."
},
"ui_files": {
"title": "הוספה לתצוגת הקבצים",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -1940,6 +1940,14 @@
"above_quote": "引用テキストの前",
"below_quote": "引用テキストの後"
},
"signature_new_mail": {
"label": "新規メールの署名",
"description": "新しいメールを作成するときに署名を自動的に挿入します。オフにすると署名なしで作成を始められます。"
},
"signature_reply_forward": {
"label": "返信・転送の署名",
"description": "返信や転送のときに署名を自動的に追加します。オフにすると署名なしで送信されます。"
},
"signature_separator": {
"label": "署名区切り",
"description": "署名の前に標準の区切り行「-- 」(RFC 3676)を付けます。本文から署名へ直接続けたい場合はオフにしてください。"
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "タスク画面に追加",
"body": "タスクの詳細に独自のセクションを、タスク一覧にボタンを表示します。"
"body": "タスクの詳細に独自のセクションを、タスク一覧にボタンを表示し、Bulwark のタスク一覧の代わりに独自の一覧を表示します。"
},
"ui_files": {
"title": "ファイル画面に追加",
+9 -1
View File
@@ -1949,6 +1949,14 @@
"above_quote": "인용 텍스트 앞",
"below_quote": "인용 텍스트 뒤"
},
"signature_new_mail": {
"label": "새 메일 서명",
"description": "새 메일을 작성할 때 서명을 자동으로 삽입합니다. 끄면 서명 없이 새 메일을 시작합니다."
},
"signature_reply_forward": {
"label": "답장·전달 서명",
"description": "답장이나 전달할 때 서명을 자동으로 추가합니다. 끄면 서명 없이 보냅니다."
},
"signature_separator": {
"label": "서명 구분선",
"description": "서명 앞에 표준 구분선 \"-- \" (RFC 3676)을 추가합니다. 본문에서 바로 서명으로 이어지길 원하면 해제하세요."
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "할 일 화면에 추가",
"body": "할 일 세부 정보에 자체 섹션을, 할 일 목록에 버튼을 표시해요."
"body": "할 일 세부 정보에 자체 섹션을, 할 일 목록에 버튼을 표시하고, Bulwark의 할 일 목록 대신 자체 목록을 표시해요."
},
"ui_files": {
"title": "파일 화면에 추가",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -1952,6 +1952,14 @@
"above_quote": "Ишлэлээс өмнө",
"below_quote": "Иш иш татсаны дараа"
},
"signature_new_mail": {
"label": "Шинэ захидалд гарын үсэг",
"description": "Шинэ захидал бичих үед гарын үсгээ автоматаар оруулна. Шинэ захидал бүрийг гарын үсэггүй эхлүүлэх бол унтраана уу."
},
"signature_reply_forward": {
"label": "Хариу, дамжуулахад гарын үсэг",
"description": "Захидалд хариу өгөх эсвэл дамжуулах үед гарын үсгээ автоматаар хавсаргана. Гарын үсэггүй илгээхийн тулд унтраана уу."
},
"signature_separator": {
"label": "Гарын үсэг хязгаарлагч",
"description": "Гарын үсгийн угтварыг стандарт \"--\" хязгаарлагч шугамаар (RFC 3676) оруулна. Хэрэв та мессежээсээ шууд гарын үсэг рүү шилжихийг хүсвэл унтраа."
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "Даалгаврын харагдацад нэмэх",
"body": "Даалгаврын дэлгэрэнгүйд өөрийн хэсгүүдийг, даалгаврын жагсаалтад товчуудыг харуулах."
"body": "Даалгаврын дэлгэрэнгүйд өөрийн хэсгүүдийг, даалгаврын жагсаалтад товчуудыг, мөн Bulwark-ийн жагсаалтын оронд өөрийн даалгаврын жагсаалтыг харуулах."
},
"ui_files": {
"title": "Файлын харагдацад нэмэх",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -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ă."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -1940,6 +1940,14 @@
"above_quote": "Перед цитируемым текстом",
"below_quote": "После цитируемого текста"
},
"signature_new_mail": {
"label": "Подпись в новых письмах",
"description": "Автоматически вставляет подпись при написании нового письма. Отключите, чтобы начинать каждое новое письмо без неё."
},
"signature_reply_forward": {
"label": "Подпись в ответах и пересылках",
"description": "Автоматически добавляет подпись при ответе или пересылке письма. Отключите, чтобы отправлять их без неё."
},
"signature_separator": {
"label": "Разделитель подписи",
"description": "Добавлять перед подписью стандартную строку-разделитель \"-- \" (RFC 3676). Отключите, если хотите переходить от текста сразу к подписи."
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "Добавлять элементы в интерфейс задач",
"body": "Показывать свои разделы в сведениях о задаче и кнопки в списке задач."
"body": "Показывать свои разделы в сведениях о задаче, кнопки в списке задач и свой список задач вместо списка Bulwark."
},
"ui_files": {
"title": "Добавлять элементы в интерфейс файлов",
+9 -1
View File
@@ -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)."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -2973,7 +2973,7 @@
},
"ui_tasks": {
"title": "เพิ่มองค์ประกอบในรายการงาน",
"body": "แสดงส่วนข้อมูลของปลั๊กอินเองในรายละเอียดของงานและปุ่มในรายการงาน"
"body": "แสดงส่วนข้อมูลของปลั๊กอินเองในรายละเอียดของงาน ปุ่มในรายการงาน และรายการงานของปลั๊กอินเองแทนรายการของ Bulwark"
},
"ui_files": {
"title": "เพิ่มองค์ประกอบในไฟล์",
@@ -4621,6 +4621,14 @@
"above_quote": "ก่อนข้อความที่อ้างอิง",
"below_quote": "หลังข้อความที่อ้างอิง"
},
"signature_new_mail": {
"label": "ลายเซ็นในอีเมลใหม่",
"description": "แทรกลายเซ็นของคุณโดยอัตโนมัติเมื่อเขียนอีเมลใหม่ ปิดใช้งานหากต้องการเริ่มอีเมลใหม่ทุกฉบับโดยไม่มีลายเซ็น"
},
"signature_reply_forward": {
"label": "ลายเซ็นในการตอบกลับและส่งต่อ",
"description": "แนบลายเซ็นของคุณโดยอัตโนมัติเมื่อตอบกลับหรือส่งต่ออีเมล ปิดใช้งานหากต้องการส่งโดยไม่มีลายเซ็น"
},
"signature_separator": {
"label": "เส้นคั่นลายเซ็น",
"description": "ใส่เส้นคั่นมาตรฐาน \"-- \" นำหน้าลายเซ็น (RFC 3676) ปิดใช้งานหากคุณต้องการให้ข้อความของคุณไหลต่อเนื่องเข้าสู่ลายเซ็นทันที"
+9 -1
View File
@@ -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."
@@ -6055,7 +6063,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",
+9 -1
View File
@@ -1949,6 +1949,14 @@
"above_quote": "Перед цитованим текстом",
"below_quote": "Після цитованого тексту"
},
"signature_new_mail": {
"label": "Підпис у нових листах",
"description": "Автоматично вставляє підпис під час написання нового листа. Вимкніть, щоб кожен новий лист починався без нього."
},
"signature_reply_forward": {
"label": "Підпис у відповідях і пересиланнях",
"description": "Автоматично додає підпис під час відповіді або пересилання листа. Вимкніть, щоб надсилати їх без нього."
},
"signature_separator": {
"label": "Розділювач підпису",
"description": "Додавати перед підписом стандартний рядок-розділювач \"-- \" (RFC 3676). Вимкніть, якщо хочете переходити з повідомлення відразу до підпису."
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "Додавати елементи в інтерфейс завдань",
"body": "Показувати власні розділи в подробицях завдання й кнопки в списку завдань."
"body": "Показувати власні розділи в подробицях завдання, кнопки в списку завдань і власний список завдань замість списку Bulwark."
},
"ui_files": {
"title": "Додавати елементи в інтерфейс файлів",
+9 -1
View File
@@ -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ý."
@@ -6047,7 +6055,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",
+9 -1
View File
@@ -1952,6 +1952,14 @@
"above_quote": "引用文字之前",
"below_quote": "引用文字之後"
},
"signature_new_mail": {
"label": "新郵件簽名",
"description": "撰寫新郵件時自動插入簽名。關閉後每封新郵件都不帶簽名。"
},
"signature_reply_forward": {
"label": "回覆轉寄簽名",
"description": "回覆或轉寄時自動附加簽名。關閉後回覆和轉寄不帶簽名。"
},
"signature_separator": {
"label": "簽名檔分隔線",
"description": "在簽名檔前加入標準的「-- 」分隔線(RFC 3676)。若希望內文直接接續簽名檔,請停用此選項。"
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "新增到工作畫面",
"body": "在工作詳細資料中顯示它自己的區塊,並在工作清單中顯示按鈕。"
"body": "在工作詳細資料中顯示它自己的區塊,在工作清單中顯示按鈕,並可用它自己的工作清單取代 Bulwark 的工作清單。"
},
"ui_files": {
"title": "新增到檔案畫面",
+9 -1
View File
@@ -1949,6 +1949,14 @@
"above_quote": "引用文本之前",
"below_quote": "引用文本之后"
},
"signature_new_mail": {
"label": "新邮件签名",
"description": "写新邮件时自动插入签名。关闭后每封新邮件都不带签名。"
},
"signature_reply_forward": {
"label": "回复转发签名",
"description": "回复或转发时自动附加签名。关闭后回复和转发不带签名。"
},
"signature_separator": {
"label": "签名分隔符",
"description": "在签名前加入标准的 “-- ” 分隔行(RFC 3676)。如果希望正文直接连到签名,请关闭。"
@@ -6055,7 +6063,7 @@
},
"ui_tasks": {
"title": "添加到任务界面",
"body": "在任务详情中显示它自己的区块,并在任务列表中显示按钮。"
"body": "在任务详情中显示它自己的区块,在任务列表中显示按钮,并可用它自己的任务列表代替 Bulwark 的任务列表。"
},
"ui_files": {
"title": "添加到文件界面",
+11 -8
View File
@@ -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,19 @@ 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);
// 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.
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
@@ -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, unknown>, string];
type RequestTarget = { request(calls: MethodCall[]): Promise<unknown> };
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();
});
});
@@ -52,6 +52,41 @@ describe('settings sync debounce', () => {
});
});
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(true);
const exported = JSON.parse(settings.exportSettings()) as Record<string, unknown>;
expect(exported).toMatchObject({
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
});
});
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');
+24 -3
View File
@@ -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<ContactCard>) {
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<string>, b: Set<string>): 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<ContactStore>()(
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<ContactStore>()(
}));
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<ContactStore>()(
};
});
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<ContactStore>()(
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<ContactStore>()(
}));
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<ContactStore>()(
};
});
void contactHooks.onAfterContactDelete.emit({ contactIds: [id] });
notifyBirthdayCalendarIfEnabled();
return true;
},
@@ -1202,6 +1219,7 @@ export const useContactStore = create<ContactStore>()(
});
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<ContactStore>()(
}
}
if (imported > 0) void contactHooks.onContactsImport.emit({ addressBookId, imported });
if (imported > 0) {
void contactHooks.onContactsImport.emit({ addressBookId, imported });
notifyBirthdayCalendarIfEnabled();
}
return imported;
},
});
+17 -1
View File
@@ -10,7 +10,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';
@@ -591,6 +592,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
@@ -931,6 +934,10 @@ const DEFAULT_SETTINGS = {
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition,
// 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: true,
signatureDetails: EMPTY_SIGNATURE_DETAILS,
requestReadReceiptDefault: false,
@@ -1311,6 +1318,13 @@ export const useSettingsStore = create<SettingsState>()(
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 => {
@@ -1365,6 +1379,8 @@ export const useSettingsStore = create<SettingsState>()(
subAddressDelimiter: state.subAddressDelimiter,
sendDelaySeconds: state.sendDelaySeconds,
signaturePosition: state.signaturePosition,
signatureNewMailEnabled: state.signatureNewMailEnabled,
signatureReplyForwardEnabled: state.signatureReplyForwardEnabled,
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
signatureDetails: state.signatureDetails,
requestReadReceiptDefault: state.requestReadReceiptDefault,