From 9bff740a89d5857742949059cb51f63d1a983b69 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:27:02 +0200 Subject: [PATCH 001/147] fix: keep plugin network access to the origins in its manifest --- .../plugin-host-bridge-origin.test.ts | 50 +++++++++++++ lib/__tests__/plugin-http-fetch.test.ts | 75 +++++++++++++++++++ .../plugin-sandbox-route-gate.test.ts | 31 ++++++++ lib/plugin-sandbox/host-api.ts | 50 ++++++++++++- lib/plugin-sandbox/host-bridge.ts | 8 ++ proxy.ts | 15 +++- 6 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/plugin-host-bridge-origin.test.ts create mode 100644 lib/__tests__/plugin-http-fetch.test.ts diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts new file mode 100644 index 000000000..415b7a016 --- /dev/null +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; + +vi.mock('../plugin-sandbox/host-api', () => ({ + dispatchApiCall: vi.fn(async () => 'ok'), +})); + +import { createBackgroundInstance } from '../plugin-sandbox/host-bridge'; +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +function plugin(): InstalledPlugin { + return { + id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, + }; +} + +function post(source: Window | null, origin: string, data: unknown) { + window.dispatchEvent(new MessageEvent('message', { data, origin, source })); +} + +const instances: Array<{ destroy(): void }> = []; +afterEach(() => { + for (const i of instances.splice(0)) i.destroy(); + vi.clearAllMocks(); +}); + +describe('host bridge origin pin for untrusted sandboxes', () => { + it('answers API requests from the null-origin sandbox document', async () => { + const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }); + instances.push(inst); + + post(inst.iframe.contentWindow, 'null', { type: 'api-request', id: '1', method: 'storage.keys', args: [] }); + await new Promise((r) => setTimeout(r, 0)); + + expect(dispatchApiCall).toHaveBeenCalledTimes(1); + }); + + it('ignores the same frame once it has navigated to another origin', async () => { + // A sandboxed frame may navigate itself. The page it lands on is no + // longer under the sandbox CSP, so it must not inherit the RPC channel. + const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }); + instances.push(inst); + + post(inst.iframe.contentWindow, 'https://attacker.example', { type: 'api-request', id: '1', method: 'storage.keys', args: [] }); + await new Promise((r) => setTimeout(r, 0)); + + expect(dispatchApiCall).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/plugin-http-fetch.test.ts b/lib/__tests__/plugin-http-fetch.test.ts new file mode 100644 index 000000000..55de6dbad --- /dev/null +++ b/lib/__tests__/plugin-http-fetch.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { dispatchApiCall, MAX_PLUGIN_FETCH_BYTES } from '../plugin-sandbox/host-api'; + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'p1', + name: 'Test plugin', + version: '1.0.0', + author: 'test', + description: '', + type: 'hook', + permissions: ['http:fetch'], + grantedPermissions: ['http:fetch'], + httpOrigins: ['https://api.example.com'], + entrypoint: 'index.js', + enabled: true, + status: 'running', + settings: {}, + ...overrides, + }; +} + +/** A fetch Response whose final URL is `url`, as after following redirects. */ +function responseAt(url: string, body: BodyInit = '{"ok":true}', init: ResponseInit = {}): Response { + const res = new Response(body, { status: 200, ...init }); + Object.defineProperty(res, 'url', { value: url }); + return res; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('http.fetch', () => { + it('returns the response when it stays on an allowlisted origin', async () => { + vi.stubGlobal('fetch', vi.fn(async () => responseAt('https://api.example.com/v1/items'))); + + const result = await dispatchApiCall(plugin(), 'http.fetch', ['https://api.example.com/v1/items']) as { status: number; bodyText: string }; + + expect(result.status).toBe(200); + expect(result.bodyText).toBe('{"ok":true}'); + }); + + it('refuses a response that a redirect moved off the allowlist', async () => { + // The request carries the webmail origin, so a server that trusts that + // origin in CORS would answer it; the plugin must not get to read it. + vi.stubGlobal('fetch', vi.fn(async () => responseAt('https://mail.example.org/jmap/session', '{"secret":1}'))); + + await expect(dispatchApiCall(plugin(), 'http.fetch', ['https://api.example.com/redirect'])) + .rejects.toThrow(/redirected to https:\/\/mail\.example\.org/); + }); + + it('refuses a body larger than the cap', async () => { + const big = new Uint8Array(MAX_PLUGIN_FETCH_BYTES + 1); + vi.stubGlobal('fetch', vi.fn(async () => responseAt('https://api.example.com/big', big))); + + await expect(dispatchApiCall(plugin(), 'http.fetch', ['https://api.example.com/big'])) + .rejects.toThrow(/larger than/); + }); + + it('still refuses an origin that is not in the allowlist', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect(dispatchApiCall(plugin(), 'http.fetch', ['https://evil.example/'])) + .rejects.toThrow(/not in plugin httpOrigins/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/plugin-sandbox-route-gate.test.ts b/lib/__tests__/plugin-sandbox-route-gate.test.ts index ffd3bb318..6032ca8e0 100644 --- a/lib/__tests__/plugin-sandbox-route-gate.test.ts +++ b/lib/__tests__/plugin-sandbox-route-gate.test.ts @@ -92,6 +92,37 @@ describe('plugin sandbox routes (GHSA-96cx-gx36-3g79)', () => { expect((await load(path)).status).toBe(403); }); + // H-03: a plugin's network access must go through api.http.fetch, which + // checks the manifest's httpOrigins. If the iframe itself may reach any + // https origin, a plugin with only email:read can post every message it + // sees to its own server with fetch(), an , a CSS url() or a font. + it.each(SANDBOX_ROUTES)('gives %s no direct network access beyond its own origin', async (path) => { + const response = await load(path, IFRAME_LOAD); + const directives: Record = Object.fromEntries( + (response.headers.get('content-security-policy') ?? '') + .split(';') + .map((d) => d.trim().split(/\s+/)) + .map(([name, ...sources]) => [name, sources]), + ); + + for (const name of ['connect-src', 'img-src', 'font-src', 'media-src', 'default-src']) { + const sources = directives[name] ?? []; + for (const wide of ['https:', 'http:', 'ws:', 'wss:', '*']) { + expect(sources, `${name} allows ${wide}`).not.toContain(wide); + } + } + // The router refetches the sandbox page itself, so its own origin stays. + expect(directives['connect-src']).toEqual(["'self'"]); + }); + + it('keeps https images and connections for the mail UI itself', async () => { + const response = await load('/en/mail', { 'sec-fetch-dest': 'document' }); + const csp = response.headers.get('content-security-policy') ?? ''; + + expect(csp).toMatch(/img-src [^;]*https:/); + expect(csp).toMatch(/connect-src [^;]*https:/); + }); + it('leaves ordinary pages alone and never hands them the eval CSP', async () => { const response = await load('/en/calendar', { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'none' }); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index cec33a952..cf64ec690 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -481,6 +481,42 @@ interface PluginFetchInit { body?: string | ArrayBuffer | ArrayBufferView | null; } +/** + * Largest response body http.fetch hands to a plugin. The body is copied + * across postMessage twice (text and bytes), so an unbounded one could take + * the tab down. Bigger transfers go through the server proxy. + */ +export const MAX_PLUGIN_FETCH_BYTES = 25 * 1024 * 1024; + +async function readCapped(res: Response, limit: number): Promise { + const declared = Number(res.headers.get('content-length')); + if (Number.isFinite(declared) && declared > limit) { + void res.body?.cancel().catch(() => {}); + throw new Error(`Response is larger than ${limit} bytes`); + } + if (!res.body) return res.arrayBuffer(); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + void reader.cancel().catch(() => {}); + throw new Error(`Response is larger than ${limit} bytes`); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out.buffer; +} + async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: PluginFetchInit) { if (typeof rawUrl !== 'string') throw new Error('url must be a string'); let url: URL; @@ -508,11 +544,23 @@ async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: Plugi mode: 'cors', redirect: 'follow', }); + // fetch() can't stop at a cross-origin redirect and still tell us where it + // was going, so it follows, and the plugin only gets the answer if the + // last hop is still allowlisted. The request carries the webmail origin, + // so a host that trusts that origin in CORS would otherwise answer it. + if (res.url) { + let finalUrl: URL | null = null; + try { finalUrl = new URL(res.url); } catch { /* opaque */ } + if (!finalUrl || !originMatchesAllowlist(finalUrl, allowlist)) { + void res.body?.cancel().catch(() => {}); + throw new Error(`Request was redirected to ${finalUrl?.origin ?? 'an unknown origin'}, which is not in plugin httpOrigins allowlist`); + } + } // Sandboxed plugin can't hold a Response object across the boundary, so // we read the body once and return it as text + arrayBuffer (base64). const headers: Record = {}; res.headers.forEach((val, key) => { headers[key.toLowerCase()] = val; }); - const buf = await res.arrayBuffer(); + const buf = await readCapped(res, MAX_PLUGIN_FETCH_BYTES); let text: string | null = null; try { text = new TextDecoder('utf-8', { fatal: false }).decode(buf); } catch { text = null; } return { diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index cd0cf51b7..5b3f67ca6 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -191,6 +191,14 @@ export class SandboxInstance { // are null-origin (event.origin === "null") in prod and can't be pinned // this way, so the contentWindow check above is the sole gate for them. if (this.privileged && ev.origin !== window.location.origin) return; + // Untrusted iframes are opaque-origin, so their messages say "null". A + // sandboxed frame can still navigate itself; the page it lands on runs + // outside the sandbox CSP and must not inherit the RPC channel. Dev mode + // adds allow-same-origin (see the sandbox flags), so it posts as us. + if (!this.privileged) { + const expected = process.env.NODE_ENV === 'development' ? window.location.origin : 'null'; + if (ev.origin !== expected) return; + } const msg = ev.data as SandboxToHost; if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return; diff --git a/proxy.ts b/proxy.ts index ccc08fc87..c5666892b 100644 --- a/proxy.ts +++ b/proxy.ts @@ -261,7 +261,16 @@ export async function proxy(request: NextRequest) { ? `'self' 'nonce-${nonce}' 'unsafe-eval'` : `'self' 'nonce-${nonce}'`; - const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`; + // A plugin reaches the network only through api.http.fetch, which the host + // checks against the manifest's httpOrigins (H-03). The sandbox document + // itself gets its own origin and nothing else, so fetch(), images, fonts + // and CSS url() can't carry data to a host the manifest didn't name. Its + // own origin stays because the router refetches the page as RSC. + const connectSrc = isSandboxPath + ? `'self'` + : isDev ? `'self' http: https: ws: wss:` : `'self' https:`; + const imgSrc = isSandboxPath ? `'self' data: blob:` : `'self' data: blob: https:`; + const fontSrc = isSandboxPath ? `'self' data:` : `'self' https: data:`; // The admin dashboard and the setup wizard are never framed. The embedding // allowance is for the mail UI a portal wraps; a framed admin page is a @@ -321,8 +330,8 @@ export async function proxy(request: NextRequest) { `default-src 'self'`, `script-src ${scriptSrc}`, `style-src 'self' 'unsafe-inline'`, - `img-src 'self' data: blob: https:`, - `font-src 'self' https: data:`, + `img-src ${imgSrc}`, + `font-src ${fontSrc}`, `connect-src ${connectSrc}`, frameSrc, `object-src 'self' blob:`, From c45533646e876ee0aaaf7ffaf514c6563aed03a7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:31:30 +0200 Subject: [PATCH 002/147] fix: give slot plugins the message, event or contact only with the matching read permission --- components/email/email-viewer.tsx | 2 +- .../plugin-iframe-slot-props.test.tsx | 69 +++++++++++++++++++ components/plugins/plugin-iframe-slot.tsx | 23 +++++-- lib/__tests__/plugin-slot-props.test.ts | 63 +++++++++++++++++ lib/plugin-sandbox/permissions.ts | 48 +++++++++++++ 5 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 components/plugins/__tests__/plugin-iframe-slot-props.test.tsx create mode 100644 lib/__tests__/plugin-slot-props.test.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 07463f525..7da9207fd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -6103,7 +6103,7 @@ export function EmailViewer({
- +
diff --git a/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx new file mode 100644 index 000000000..46e7ede21 --- /dev/null +++ b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +const evaluateShouldShow = vi.fn(async () => true); +let activePlugin: InstalledPlugin; +let hasShouldShow = false; + +vi.mock('@/lib/plugin-sandbox/registry', () => ({ + get: () => ({ + plugin: activePlugin, + code: '', + tier: 'untrusted', + background: { evaluateShouldShow }, + slotOffers: [{ name: 'email-banner', hasShouldShow, order: 100 }], + }), +})); + +const createSlotInstance = vi.fn(() => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + createSlotInstance: (...args: unknown[]) => createSlotInstance(...(args as [])), +})); +vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) })); + +import { PluginIframeSlot } from '../plugin-iframe-slot'; + +function plugin(perms: string[]): InstalledPlugin { + return { + id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension', + permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const EMAIL = { id: 'm1', text: 'confidential body' }; + +beforeEach(() => { + vi.clearAllMocks(); + hasShouldShow = false; +}); + +describe('PluginIframeSlot', () => { + it('mounts a banner-only plugin without the opened message', async () => { + activePlugin = plugin(['ui:email-banner']); + render(); + + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { extraProps: Record }; + expect(opts.extraProps.email).toBeUndefined(); + }); + + it('does not show the message to shouldShow either', async () => { + activePlugin = plugin(['ui:email-banner']); + hasShouldShow = true; + render(); + + await waitFor(() => expect(evaluateShouldShow).toHaveBeenCalled()); + expect((evaluateShouldShow.mock.calls[0] as unknown[])[1]).toEqual({}); + }); + + it('hands the message to a plugin that may read mail', async () => { + activePlugin = plugin(['ui:email-banner', 'email:read']); + render(); + + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { extraProps: Record }; + expect(opts.extraProps.email).toEqual(EMAIL); + }); +}); diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index b2b1e5b23..8c372df27 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -6,10 +6,11 @@ // null-origin context; its height is pushed back via postMessage and applied // to a wrapper
. -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { SlotName } from '@/lib/plugin-types'; import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge'; +import { slotPropsFor } from '@/lib/plugin-sandbox/permissions'; import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme'; import { useThemeStore } from '@/stores/theme-store'; @@ -26,6 +27,14 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { // null = pending, true/false = decided const [show, setShow] = useState(null); + // What the plugin may see of the surface the slot sits on. Everything the + // plugin receives (shouldShow context, mount props, updates) goes through + // this, so a ui:* permission never hands over mail, events or contacts. + const props = useMemo(() => { + const active = getActivePlugin(pluginId); + return active ? slotPropsFor(active.plugin, slot, extraProps ?? {}) : {}; + }, [pluginId, slot, extraProps]); + // Decide whether to mount based on the plugin's shouldShow (background-side). useEffect(() => { const active = getActivePlugin(pluginId); @@ -35,11 +44,11 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { if (!offer.hasShouldShow) { setShow(true); return; } let cancelled = false; active.background - .evaluateShouldShow(slot, extraProps ?? {}) + .evaluateShouldShow(slot, props) .then((s) => { if (!cancelled) setShow(s); }) .catch(() => { if (!cancelled) setShow(false); }); return () => { cancelled = true; }; - }, [pluginId, slot, extraProps]); + }, [pluginId, slot, props]); // Spawn / tear down the slot iframe. useEffect(() => { @@ -53,7 +62,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { code: active.code, locale, tier: active.tier, - extraProps: extraProps ?? {}, + extraProps: props, hostContainer: wrapperRef.current, onResize: (h) => setHeight(h), }); @@ -62,15 +71,15 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { try { inst.destroy(); } catch { /* ignore */ } instanceRef.current = null; }; - // We intentionally don't depend on extraProps here - propagating prop + // We intentionally don't depend on props here - propagating prop // changes happens via postMessage below to avoid iframe churn. // eslint-disable-next-line react-hooks/exhaustive-deps }, [show, pluginId, slot]); // Push prop updates without remount. useEffect(() => { - instanceRef.current?.updateProps(extraProps ?? {}); - }, [extraProps]); + instanceRef.current?.updateProps(props); + }, [props]); // Re-theme the live slot iframe when the host theme changes (dark/light // toggle or custom theme switch), without tearing down the iframe. Reacting diff --git a/lib/__tests__/plugin-slot-props.test.ts b/lib/__tests__/plugin-slot-props.test.ts new file mode 100644 index 000000000..2e1ac4e02 --- /dev/null +++ b/lib/__tests__/plugin-slot-props.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import type { InstalledPlugin, Permission } from '../plugin-types'; +import { slotPropsFor } from '../plugin-sandbox/permissions'; + +function plugin(perms: Permission[]): InstalledPlugin { + return { + id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension', + permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const EMAIL = { id: 'm1', subject: 'Salary review', text: 'confidential body' }; + +describe('slot props follow read permissions', () => { + it('withholds the opened message from an email-banner plugin without email:read', () => { + // ui:email-banner only lets a plugin draw a banner. Handing it the body, + // headers and source would make it as good as email:read. + const props = slotPropsFor(plugin(['ui:email-banner']), 'email-banner', { email: EMAIL }); + + expect(props.email).toBeUndefined(); + }); + + it('passes the message once the plugin also holds email:read', () => { + const props = slotPropsFor(plugin(['ui:email-banner', 'email:read']), 'email-banner', { email: EMAIL }); + + expect(props.email).toEqual(EMAIL); + }); + + it.each([ + ['email-details-section', 'email', 'email:read'], + ['email-detail-sidebar', 'email', 'email:read'], + ['calendar-event-actions', 'eventData', 'calendar:read'], + ['contact-cryptokeys', 'cryptoKeys', 'contacts:read'], + ['app-top-banner', 'username', 'account:read'], + ['app-top-banner', 'serverUrl', 'account:read'], + ] as const)('%s.%s needs %s', (slot, key, perm) => { + const without = slotPropsFor(plugin([]), slot, { [key]: 'value' }); + const withPerm = slotPropsFor(plugin([perm]), slot, { [key]: 'value' }); + + expect(without[key]).toBeUndefined(); + expect(withPerm[key]).toBe('value'); + }); + + it('drops props a slot does not declare', () => { + const props = slotPropsFor(plugin(['email:read']), 'email-footer', { email: EMAIL, extra: 1 }); + + expect(props).toEqual({}); + }); + + it('keeps host callbacks that are part of the slot contract', () => { + const onAttach = () => {}; + const props = slotPropsFor(plugin([]), 'composer-attachment-source', { onAttach }); + + expect(props.onAttach).toBe(onAttach); + }); + + it('passes a plugin dialog its own props unchanged', () => { + const props = slotPropsFor(plugin([]), 'plugin-dialog', { anything: { nested: true }, onResult: () => {} }); + + expect(Object.keys(props).sort()).toEqual(['anything', 'onResult']); + }); +}); diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index 1f298b51e..a0ddfa85d 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -288,6 +288,54 @@ const SLOT_PERMISSIONS: Record = { 'plugin-dialog': ['ui:observe'], }; +/** + * The props each slot may hand its plugin, and the permission each needs + * (null: none beyond the slot's own). A ui:* permission only lets a plugin + * draw somewhere; the message, event, contact or account the slot sits on is + * data, and stays behind the matching read permission. Props not listed here + * are dropped, so a new prop at a call site is withheld until it is added. + */ +const SLOT_PROPS: Record | '*'> = { + 'toolbar-actions': {}, + 'app-top-banner': { username: 'account:read', serverUrl: 'account:read' }, + 'email-banner': { email: 'email:read' }, + 'email-footer': {}, + 'composer-toolbar': {}, + 'composer-sidebar': {}, + 'composer-sidebar-right': {}, + 'composer-attachment-source': { onAttach: null }, + 'sidebar-widget': {}, + 'email-detail-sidebar': { email: 'email:read' }, + 'email-details-section': { email: 'email:read', category: null }, + 'settings-section': {}, + 'context-menu-email': {}, + 'navigation-rail-bottom': {}, + 'calendar-event-actions': { eventData: 'calendar:read', setVirtualLocation: null }, + 'admin-plugin-page': {}, + 'contact-cryptokeys': { cryptoKeys: 'contacts:read' }, + 'attachment-actions': { attachment: null }, + // The plugin chose these props itself when it opened the dialog. + 'plugin-dialog': '*', +}; + +export function slotPropsFor( + plugin: InstalledPlugin, + slot: SlotName, + props: Record, +): Record { + const allowed = SLOT_PROPS[slot]; + if (allowed === '*') return props; + if (!allowed) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue; + const perm = allowed[key]; + if (perm && !pluginHasPermission(plugin, perm)) continue; + out[key] = value; + } + return out; +} + export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean { const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot) ? SLOT_PERMISSIONS[slot as SlotName] From 468c1aac0c100a05bb0fb9bb7d17c1f84d9253f8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:32:44 +0200 Subject: [PATCH 003/147] fix: refuse marketplace bundles whose id differs from the directory entry --- .../admin/marketplace/__tests__/route.test.ts | 87 +++++++++++++++++++ app/api/admin/marketplace/route.ts | 11 ++- 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/marketplace/__tests__/route.test.ts diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts new file mode 100644 index 000000000..069a7242b --- /dev/null +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(() => 'https://extensions.test'), + }, +})); + +const registry = new Map>(); +vi.mock('@/lib/admin/plugin-registry', () => ({ + savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }), + saveTheme: vi.fn(async () => {}), + getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null), + getTheme: vi.fn(async () => null), + getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })), + getThemeRegistry: vi.fn(async () => ({ themes: [] })), +})); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + invalidateFrameOriginsCache: vi.fn(), +})); + +import { POST } from '../route'; + +async function bundle(manifest: Record, code = 'module.exports = {};'): Promise { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest)); + zip.file('index.js', code); + const buf = await zip.generateAsync({ type: 'uint8array' }); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +function install(body: Record) { + return POST(new NextRequest('http://localhost/api/admin/marketplace', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + })); +} + +const MANIFEST = { + name: 'Pretty Dates', version: '1.0.0', author: 'x', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +beforeEach(() => { + registry.clear(); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('POST /api/admin/marketplace', () => { + it('installs a plugin under its directory slug', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pretty-dates' })))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(200); + expect(registry.has('pretty-dates')).toBe(true); + }); + + it('refuses a bundle whose manifest id differs from the slug', async () => { + // Otherwise a directory entry can overwrite another installed plugin, + // inheriting its enabled and force-enabled state, while the admin UI + // (which keys install status by slug) still shows "not installed". + registry.set('pgp-plugin', { id: 'pgp-plugin', forceEnabled: true, version: '2.0.0' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pgp-plugin' })))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/does not match/); + expect(registry.get('pgp-plugin')?.version).toBe('2.0.0'); + }); +}); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index b218c4ea3..1dfba54da 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -185,7 +185,16 @@ export async function POST(request: NextRequest) { // bundles are authored by a third-party publisher; without this an id // like "../../foo" causes savePlugin/saveTheme to write outside the // plugins/themes dir via path.join. - const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug; + // The slug is what the admin picked and what install status is keyed + // on. A bundle that names another id would install over whatever + // already uses that id, keeping its enabled and force-enabled state. + if (manifest.id !== undefined && manifest.id !== slug) { + return NextResponse.json( + { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, + { status: 400 }, + ); + } + const resolvedId = slug; if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) { return NextResponse.json( { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' }, From 0dd04ed21de134ebcbdece203853d9cd45e0f82a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:34:40 +0200 Subject: [PATCH 004/147] fix: stop forcing a theme after the admin deletes or disables it --- app/api/admin/themes/__tests__/route.test.ts | 50 ++++++++++++++++ app/api/admin/themes/route.ts | 2 + lib/__tests__/theme-store-sync.test.ts | 62 ++++++++++++++++++++ lib/admin/extension-cleanup.ts | 15 +++++ stores/theme-store.ts | 23 ++++++++ 5 files changed, 152 insertions(+) create mode 100644 app/api/admin/themes/__tests__/route.test.ts create mode 100644 lib/__tests__/theme-store-sync.test.ts create mode 100644 lib/admin/extension-cleanup.ts diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts new file mode 100644 index 000000000..6f11c7a7d --- /dev/null +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); + +let policy: Record; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(), + getPolicy: vi.fn(() => policy), + setPolicy: vi.fn(async (p: Record) => { policy = p; }), + }, +})); +vi.mock('@/lib/admin/plugin-registry', () => ({ + deleteTheme: vi.fn(async () => true), + saveTheme: vi.fn(), + getTheme: vi.fn(async () => null), + getThemeRegistry: vi.fn(async () => ({ themes: [] })), +})); + +import { DELETE } from '../route'; + +function del(id: string) { + return DELETE(new NextRequest('http://localhost/api/admin/themes', { + method: 'DELETE', + body: JSON.stringify({ id }), + headers: { 'content-type': 'application/json' }, + })); +} + +beforeEach(() => { + policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] }; +}); + +describe('DELETE /api/admin/themes', () => { + it('stops forcing a theme once it is deleted', async () => { + const res = await del('corp'); + + expect(res.status).toBe(200); + expect(policy.forceEnabledThemes).toEqual(['other']); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index f600d09b6..a7869c0c1 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -8,6 +8,7 @@ import { deleteTheme as removeTheme, type ServerTheme, } from '@/lib/admin/plugin-registry'; +import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; import JSZip from 'jszip'; import { MAX_THEME_SIZE } from '@/lib/plugin-types'; @@ -205,6 +206,7 @@ export async function DELETE(request: NextRequest) { if (!removed) { return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); } + await forgetExtensionInPolicy('theme', id); await auditLog('theme.delete', { id }, ip); return NextResponse.json({ success: true }); diff --git a/lib/__tests__/theme-store-sync.test.ts b/lib/__tests__/theme-store-sync.test.ts new file mode 100644 index 000000000..824fc059f --- /dev/null +++ b/lib/__tests__/theme-store-sync.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const serverThemes: { themes: unknown[] } = { themes: [] }; +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: vi.fn(async () => new Response(JSON.stringify({ plugins: [], ...serverThemes }))), +})); +vi.mock('@/lib/plugin-storage', () => ({ + pluginStorage: { + saveThemeCSS: vi.fn(async () => {}), + getThemeCSS: vi.fn(async () => null), + deleteThemeCSS: vi.fn(async () => {}), + deleteThemeSkin: vi.fn(async () => {}), + deletePreview: vi.fn(async () => {}), + getThemeSkin: vi.fn(async () => null), + }, +})); + +import { useThemeStore } from '@/stores/theme-store'; +import { pluginStorage } from '@/lib/plugin-storage'; +import type { InstalledTheme } from '@/lib/plugin-types'; + +const CORP: InstalledTheme = { + id: 'corp', name: 'Corp', version: '1.0.0', author: 'IT', description: '', + css: ':root{--color-primary:red}', variants: ['light', 'dark'], + enabled: true, builtIn: false, managed: true, forceEnabled: true, +}; + +beforeEach(() => { + serverThemes.themes = []; + const builtIns = useThemeStore.getState().installedThemes.filter((t) => t.builtIn); + useThemeStore.setState({ installedThemes: [...builtIns, CORP], activeThemeId: 'corp' }); +}); + +describe('server theme sync', () => { + it('drops a managed theme the admin deleted, and stops forcing it', async () => { + await useThemeStore.getState().syncServerThemes(); + + const state = useThemeStore.getState(); + expect(state.installedThemes.some((t) => t.id === 'corp')).toBe(false); + expect(state.activeThemeId).toBeNull(); + expect(pluginStorage.deleteThemeCSS).toHaveBeenCalledWith('corp'); + }); + + it('keeps a theme the user installed themselves', async () => { + const mine: InstalledTheme = { ...CORP, id: 'mine', managed: false, forceEnabled: false }; + useThemeStore.setState((s) => ({ installedThemes: [...s.installedThemes, mine] })); + + await useThemeStore.getState().syncServerThemes(); + + expect(useThemeStore.getState().installedThemes.some((t) => t.id === 'mine')).toBe(true); + }); + + it('leaves everything alone when the server list could not be read', async () => { + const { apiFetch } = await import('@/lib/browser-navigation'); + vi.mocked(apiFetch).mockResolvedValueOnce(new Response('nope', { status: 500 })); + + await useThemeStore.getState().syncServerThemes(); + + expect(useThemeStore.getState().installedThemes.some((t) => t.id === 'corp')).toBe(true); + }); +}); diff --git a/lib/admin/extension-cleanup.ts b/lib/admin/extension-cleanup.ts new file mode 100644 index 000000000..052e4a2f8 --- /dev/null +++ b/lib/admin/extension-cleanup.ts @@ -0,0 +1,15 @@ +import { configManager } from './config-manager'; + +/** + * Drop a deleted plugin or theme from the policy lists that refer to it by + * id. Left behind, a later upload that reuses the id would start out forced + * on for everyone without the admin asking for it. + */ +export async function forgetExtensionInPolicy(kind: 'plugin' | 'theme', id: string): Promise { + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + const key = kind === 'plugin' ? 'forceEnabledPlugins' : 'forceEnabledThemes'; + const list = policy[key] ?? []; + if (!list.includes(id)) return; + await configManager.setPolicy({ ...policy, [key]: list.filter((x) => x !== id) }); +} diff --git a/stores/theme-store.ts b/stores/theme-store.ts index e9ad9da78..3427dc5ea 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -418,6 +418,29 @@ export const useThemeStore = create()( } } + // A managed theme the server no longer lists was deleted or + // disabled by the admin. Without this it stayed installed, and + // its local forceEnabled flag kept forcing it on forever. + const serverIds = new Set(serverThemes.map(t => t.id)); + const stale = get().installedThemes.filter(t => t.managed && !t.builtIn && !serverIds.has(t.id)); + if (stale.length > 0) { + const staleIds = new Set(stale.map(t => t.id)); + const activeId = get().activeThemeId; + if (activeId && staleIds.has(activeId)) { + removeThemeCSS(); + removeThemeSkinCSS(); + set({ activeThemeId: null }); + } + for (const id of staleIds) { + void pluginStorage.deleteThemeCSS(id); + void pluginStorage.deleteThemeSkin(id); + void pluginStorage.deletePreview(id); + } + set(state => ({ + installedThemes: state.installedThemes.filter(t => !staleIds.has(t.id)), + })); + } + set(state => ({ installedThemes: dedupeInstalledThemes(state.installedThemes), })); From e57b7a2c1850aa3155883db55ef3b6184610cb33 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:43:43 +0200 Subject: [PATCH 005/147] fix: check plugin and theme packages the same way on every install path The Settings upload, the admin upload, the marketplace install and the marketplace preview each parsed the ZIP and manifest with their own copy of the code, and the copies had drifted. A `permissions` string skipped validation and was then matched by substring, a small ZIP could inflate to hundreds of megabytes on the server, the Settings upload dropped `locales` and took origin lists unchecked, and settings schemas were never checked at all. All of them now go through `lib/extension-package.ts`: - every file is inflated through a byte cap, with a limit on entries - one manifest check: types, permissions, entrypoint, version, minAppVersion, settingsSchema, configSchema, locales and origin lists - only checked fields are stored --- app/api/admin/marketplace/[slug]/route.ts | 78 +-- .../admin/marketplace/__tests__/route.test.ts | 13 + app/api/admin/marketplace/route.ts | 334 ++++-------- app/api/admin/plugins/__tests__/route.test.ts | 102 ++++ app/api/admin/plugins/route.ts | 168 ++---- app/api/admin/themes/__tests__/route.test.ts | 19 +- app/api/admin/themes/route.ts | 54 +- lib/__tests__/extension-package.test.ts | 135 +++++ lib/__tests__/plugin-store.test.ts | 22 + lib/admin/csp-frame-origins.ts | 85 +-- lib/extension-origins.ts | 81 +++ lib/extension-package.ts | 508 ++++++++++++++++++ lib/plugin-validator.ts | 254 +++------ stores/plugin-store.ts | 1 + 14 files changed, 1139 insertions(+), 715 deletions(-) create mode 100644 app/api/admin/plugins/__tests__/route.test.ts create mode 100644 lib/__tests__/extension-package.test.ts create mode 100644 lib/extension-origins.ts create mode 100644 lib/extension-package.ts diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index d62bacf60..3896f05fd 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -5,8 +5,7 @@ import { getPluginRegistry, getThemeRegistry, } from '@/lib/admin/plugin-registry'; -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { openPackage, readManifest, readText } from '@/lib/extension-package'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -79,61 +78,28 @@ export async function GET( } else { const buffer = await bundleRes.arrayBuffer(); bundleSize = buffer.byteLength; - const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; - if (buffer.byteLength > maxSize) { - bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`; - } else { - const zip = await JSZip.loadAsync(buffer); + // Opening the preview must not inflate an arbitrary bundle in + // full: every read goes through the capped package reader. + const pkg = await openPackage(buffer, type === 'theme' ? 'theme' : 'plugin'); + try { + manifest = await readManifest(pkg); + } catch (err) { + bundleError = err instanceof Error ? err.message : 'Invalid manifest.json in bundle'; + } - // Detect optional root directory inside the ZIP. - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map((e) => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - bundleError = 'Bundle missing manifest.json'; - } else { - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - bundleError = 'Invalid manifest.json in bundle'; - } - } - - if (manifest) { - if (type === 'theme') { - const cssFile = zip.file(root + 'theme.css'); - if (cssFile) { - const css = await cssFile.async('string'); - sourcePreview = { - name: 'theme.css', - content: css.length > MAX_PREVIEW_SOURCE_LEN - ? css.slice(0, MAX_PREVIEW_SOURCE_LEN) - : css, - truncated: css.length > MAX_PREVIEW_SOURCE_LEN, - }; - } - } else { - const entrypoint = (manifest.entrypoint as string) || 'index.js'; - const jsFile = zip.file(root + entrypoint); - if (jsFile) { - const code = await jsFile.async('string'); - sourcePreview = { - name: entrypoint, - content: code.length > MAX_PREVIEW_SOURCE_LEN - ? code.slice(0, MAX_PREVIEW_SOURCE_LEN) - : code, - truncated: code.length > MAX_PREVIEW_SOURCE_LEN, - }; - } - } + if (manifest) { + const name = type === 'theme' + ? 'theme.css' + : (typeof manifest.entrypoint === 'string' && manifest.entrypoint) || 'index.js'; + const content = await readText(pkg, name); + if (content !== null) { + sourcePreview = { + name, + content: content.length > MAX_PREVIEW_SOURCE_LEN + ? content.slice(0, MAX_PREVIEW_SOURCE_LEN) + : content, + truncated: content.length > MAX_PREVIEW_SOURCE_LEN, + }; } } } diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts index 069a7242b..747e39e3c 100644 --- a/app/api/admin/marketplace/__tests__/route.test.ts +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -71,6 +71,19 @@ describe('POST /api/admin/marketplace', () => { expect(registry.has('pretty-dates')).toBe(true); }); + it('refuses a ZIP bomb from the directory', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ ...MANIFEST, id: 'pretty-dates' })); + zip.file('index.js', new Uint8Array(30 * 1024 * 1024)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes as BodyInit))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(400); + expect(registry.size).toBe(0); + }); + it('refuses a bundle whose manifest id differs from the slug', async () => { // Otherwise a directory entry can overwrite another installed plugin, // inheriting its enabled and force-enabled state, while the admin UI diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 1dfba54da..f5659edff 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -12,14 +12,16 @@ import { type ServerPlugin, type ServerTheme, } from '@/lib/admin/plugin-registry'; +import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { - sanitizeFrameOrigins, - sanitizeHttpOrigins, - sanitizeApiPostPaths, - invalidateFrameOriginsCache, -} from '@/lib/admin/csp-frame-origins'; -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types'; + EXTENSION_ID_RE, + ExtensionPackageError, + openPackage, + readManifest, + readPluginPackage, + readText, + suspiciousCode, +} from '@/lib/extension-package'; import { sanitizeThemeCSS } from '@/lib/theme-loader'; import { configManager } from '@/lib/admin/config-manager'; @@ -123,6 +125,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); } + // The slug becomes a file name and the registry key; it must be a plain id. + if (typeof slug !== 'string' || !EXTENSION_ID_RE.test(slug)) { + return NextResponse.json( + { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' }, + { status: 400 }, + ); + } + // Download the bundle from the directory const directoryUrl = await getDirectoryUrl(); const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl); @@ -138,79 +148,40 @@ export async function POST(request: NextRequest) { } const buffer = await bundleRes.arrayBuffer(); - const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; - - if (buffer.byteLength > maxSize) { - return NextResponse.json( - { error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` }, - { status: 400 } - ); - } - - // Parse the ZIP - let zip: JSZip; - try { - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 }); - } - - // Find root directory - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 }); - } - const now = new Date().toISOString(); - // Resolve and strictly validate the id used as a filename. Marketplace - // bundles are authored by a third-party publisher; without this an id - // like "../../foo" causes savePlugin/saveTheme to write outside the - // plugins/themes dir via path.join. - // The slug is what the admin picked and what install status is keyed - // on. A bundle that names another id would install over whatever - // already uses that id, keeping its enabled and force-enabled state. - if (manifest.id !== undefined && manifest.id !== slug) { - return NextResponse.json( - { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, - { status: 400 }, - ); - } - const resolvedId = slug; - if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) { - return NextResponse.json( - { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' }, - { status: 400 }, - ); - } - if (type === 'theme') { - // Read theme.css - const cssFile = zip.file(root + 'theme.css'); - if (!cssFile) { + let pkg; + let manifest: Record; + try { + pkg = await openPackage(buffer, 'theme'); + manifest = await readManifest(pkg); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + // The slug is what the admin picked and what install status is keyed + // on. A bundle that names another id would install over whatever + // already uses that id, keeping its enabled and force-enabled state. + if (manifest.id !== undefined && manifest.id !== slug) { + return NextResponse.json( + { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, + { status: 400 }, + ); + } + const resolvedId = slug; + + let css: string | null; + try { + css = await readText(pkg, 'theme.css'); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + if (css === null) { return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); } - let css = await cssFile.async('string'); - // Sanitize unconditionally: the safety check is advisory, and the // sanitizer is also what drops rules outside :root and .dark. const warnings: string[] = []; @@ -255,148 +226,81 @@ export async function POST(request: NextRequest) { ); return NextResponse.json({ success: true, theme, warnings, updated: isUpdate }); - } else { - // Plugin installation - // Read entrypoint JS - const entrypoint = (manifest.entrypoint as string) || 'index.js'; - const jsFile = zip.file(root + entrypoint); - if (!jsFile) { - return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 }); - } - - const code = await jsFile.async('string'); - - // Block plugins with dangerous JS patterns - const DANGEROUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, - ]; - const dangerousFindings: string[] = []; - for (const { pattern, label } of DANGEROUS_JS_PATTERNS) { - if (pattern.test(code)) dangerousFindings.push(label); - pattern.lastIndex = 0; - } - if (dangerousFindings.length > 0) { - return NextResponse.json( - { error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` }, - { status: 400 }, - ); - } - - // Validate permissions - const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknownPerms = permissions.filter(p => !validPerms.has(p)); - - const warnings: string[] = []; - if (unknownPerms.length > 0) { - warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`); - } - - // Plugins may declare iframe origins they need for embedded content. - // Anything that doesn't pass strict origin validation is silently - // dropped - the plugin still installs, but those origins are not - // added to the host CSP. - const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const droppedFrameOrigins = Array.isArray(manifest.frameOrigins) - ? (manifest.frameOrigins as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v), - ) - : []; - if (droppedFrameOrigins.length > 0) { - warnings.push( - `Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`, - ); - } - - const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const droppedHttpOrigins = Array.isArray(manifest.httpOrigins) - ? (manifest.httpOrigins as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v), - ) - : []; - if (droppedHttpOrigins.length > 0) { - warnings.push( - `Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`, - ); - } - - const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths) - ? (manifest.apiPostPaths as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v), - ) - : []; - if (droppedApiPostPaths.length > 0) { - warnings.push( - `Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`, - ); - } - - const existingPlugin = await getPlugin(resolvedId); - const isUpdate = existingPlugin !== null; - - const plugin: ServerPlugin = { - id: resolvedId, - name: (manifest.name as string) || slug, - // See theme branch: trust the directory-published version, not - // manifest.version, so updates actually stick in the registry. - version: version || (manifest.version as string), - author: (manifest.author as string) || 'Unknown', - description: (manifest.description as string) || '', - type: (manifest.type as string) || 'hook', - ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), - permissions, - entrypoint, - enabled: existingPlugin?.enabled ?? true, - ...(existingPlugin?.forceEnabled !== undefined - ? { forceEnabled: existingPlugin.forceEnabled } - : {}), - installedAt: existingPlugin?.installedAt ?? now, - updatedAt: now, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(declaredFrameOrigins.length > 0 - ? { frameOrigins: declaredFrameOrigins } - : {}), - ...(declaredHttpOrigins.length > 0 - ? { httpOrigins: declaredHttpOrigins } - : {}), - ...(declaredApiPostPaths.length > 0 - ? { apiPostPaths: declaredApiPostPaths } - : {}), - ...(manifest.locales && typeof manifest.locales === 'object' - ? { locales: manifest.locales as ServerPlugin['locales'] } - : {}), - - }; - - await savePlugin(plugin, code); - invalidateFrameOriginsCache(); - await auditLog( - isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', - { - id: plugin.id, - name: plugin.name, - version: plugin.version, - slug, - frameOrigins: declaredFrameOrigins, - httpOrigins: declaredHttpOrigins, - apiPostPaths: declaredApiPostPaths, - ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), - }, - ip, - ); - - return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate }); } + + // Plugin installation + let pkg; + try { + pkg = await readPluginPackage(buffer, { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + expectedId: slug, + }); + } catch (err) { + if (err instanceof ExtensionPackageError) { + // Same wording the admin UI already shows for an id mismatch. + const message = err.message.replace(/Package id "([^"]*)" does not match "([^"]*)"/, 'Bundle id "$1" does not match the directory entry "$2"'); + return NextResponse.json({ error: message }, { status: 400 }); + } + throw err; + } + const { manifest, code } = pkg; + + // Block plugins with dangerous JS patterns + const suspicious = suspiciousCode(code); + if (suspicious.length > 0) { + return NextResponse.json( + { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` }, + { status: 400 }, + ); + } + + const existingPlugin = await getPlugin(slug); + const isUpdate = existingPlugin !== null; + + const plugin: ServerPlugin = { + id: slug, + name: manifest.name, + // See theme branch: trust the directory-published version, not + // manifest.version, so updates actually stick in the registry. + version: version || manifest.version, + author: manifest.author, + description: manifest.description, + type: manifest.type, + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), + permissions: manifest.permissions, + entrypoint: manifest.entrypoint, + enabled: existingPlugin?.enabled ?? true, + ...(existingPlugin?.forceEnabled !== undefined + ? { forceEnabled: existingPlugin.forceEnabled } + : {}), + installedAt: existingPlugin?.installedAt ?? now, + updatedAt: now, + ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), + ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), + ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), + ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), + ...(manifest.locales ? { locales: manifest.locales } : {}), + }; + + await savePlugin(plugin, code); + invalidateFrameOriginsCache(); + await auditLog( + isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', + { + id: plugin.id, + name: plugin.name, + version: plugin.version, + slug, + frameOrigins: plugin.frameOrigins ?? [], + httpOrigins: plugin.httpOrigins ?? [], + apiPostPaths: plugin.apiPostPaths ?? [], + ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), + }, + ip, + ); + + return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate }); } catch (error) { logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Installation failed' }, { status: 500 }); diff --git a/app/api/admin/plugins/__tests__/route.test.ts b/app/api/admin/plugins/__tests__/route.test.ts new file mode 100644 index 000000000..264a0badd --- /dev/null +++ b/app/api/admin/plugins/__tests__/route.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); + +let policy: Record; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(), + getPolicy: vi.fn(() => policy), + setPolicy: vi.fn(async (p: Record) => { policy = p; }), + }, +})); + +const registry = new Map>(); +const removedConfig: string[] = []; +vi.mock('@/lib/admin/plugin-registry', () => ({ + savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }), + getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null), + getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })), + deletePlugin: vi.fn(async (id: string) => registry.delete(id)), +})); +vi.mock('@/lib/admin/plugin-config', () => ({ + deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }), +})); +vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) })); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + invalidateFrameOriginsCache: vi.fn(), +})); + +import { POST } from '../route'; + +const MANIFEST = { + id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function upload(files: Record) { + const zip = new JSZip(); + for (const [n, d] of Object.entries(files)) zip.file(n, d); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'p.zip', { type: 'application/zip' })); + return POST(new NextRequest('http://localhost/api/admin/plugins', { method: 'POST', body: form })); +} + +beforeEach(() => { + registry.clear(); + removedConfig.length = 0; + policy = { forceEnabledPlugins: [] }; +}); + +describe('POST /api/admin/plugins', () => { + it('installs a valid plugin with its locales', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, locales: { de: { hi: 'Hallo' } } }), + 'index.js': 'module.exports = {}', + }); + + expect(res.status).toBe(200); + expect(registry.get('demo-plugin')?.locales).toEqual({ de: { hi: 'Hallo' } }); + }); + + it('refuses permissions given as a string', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, permissions: 'crypto:full email:send', tier: 'privileged' }), + 'index.js': '', + }); + + expect(res.status).toBe(400); + expect(registry.size).toBe(0); + }); + + it('refuses a ZIP bomb instead of inflating it', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify(MANIFEST), + 'index.js': new Uint8Array(30 * 1024 * 1024), + }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/once unpacked/); + }); + + it('refuses an invalid settings schema', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, settingsSchema: { m: { type: 'select', label: 'M', default: 'a', options: 'a,b' } } }), + 'index.js': '', + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index a62c4bf81..41542a25b 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -9,25 +9,9 @@ import { type ServerPlugin, } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; -import { - sanitizeFrameOrigins, - sanitizeHttpOrigins, - sanitizeApiPostPaths, - invalidateFrameOriginsCache, -} from '@/lib/admin/csp-frame-origins'; - -// Server-side extraction using the same validation logic -// ZIP parsing needs to happen on the server for admin-uploaded plugins -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; - -const SUSPICIOUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, -]; +import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; +import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; +import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; /** * GET /api/admin/plugins - List all admin-managed plugins @@ -79,141 +63,59 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 }); } - // Extract and validate ZIP - let zip: JSZip; + let pkg; try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); - } - - // Find root - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; + pkg = await readPluginPackage(await file.arrayBuffer(), { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + }); + } catch (err) { + if (err instanceof ExtensionPackageError) { + return NextResponse.json({ error: err.message }, { status: 400 }); } + throw err; } + const { manifest, code } = pkg; - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); - } - - // Validate manifest - const errors: string[] = []; - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"'); - - const validTypes = ['ui-extension', 'sidebar-app', 'hook']; - if (!validTypes.includes(manifest.type as string)) { - errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`); - } - - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - if (manifest.permissions && Array.isArray(manifest.permissions)) { - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); - if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`); - } - - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Check file extensions - for (const [filePath, entry] of Object.entries(zip.files)) { - if (entry.dir) continue; - const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : ''; - if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { - errors.push(`Disallowed file type: ${filePath}`); - } - } - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Read entrypoint code - const entryFile = zip.file(root + (manifest.entrypoint as string)); - if (!entryFile) { - return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 }); - } - const code = await entryFile.async('string'); - - // Security: block plugins containing dangerous JS patterns - const warnings: string[] = []; - for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { - if (pattern.test(code)) warnings.push(`Contains ${label}`); - pattern.lastIndex = 0; - } - if (warnings.length > 0) { + // Admin uploads reach every user, so the patterns a user upload only + // warns about are refused here. + const suspicious = suspiciousCode(code); + if (suspicious.length > 0) { return NextResponse.json( - { error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` }, + { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` }, { status: 400 }, ); } - const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - const now = new Date().toISOString(); const plugin: ServerPlugin = { - id: manifest.id as string, - name: manifest.name as string, - version: manifest.version as string, - author: manifest.author as string, - description: (manifest.description as string) || '', - type: manifest.type as string, + id: manifest.id, + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description, + type: manifest.type, ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), - permissions: (manifest.permissions as string[]) || [], - entrypoint: manifest.entrypoint as string, + permissions: manifest.permissions, + entrypoint: manifest.entrypoint, enabled: true, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(manifest.locales && typeof manifest.locales === 'object' - ? { locales: manifest.locales as ServerPlugin['locales'] } - : {}), - ...(declaredFrameOrigins.length > 0 - ? { frameOrigins: declaredFrameOrigins } - : {}), - ...(declaredHttpOrigins.length > 0 - ? { httpOrigins: declaredHttpOrigins } - : {}), - ...(declaredApiPostPaths.length > 0 - ? { apiPostPaths: declaredApiPostPaths } - : {}), + ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), + ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), + ...(manifest.locales ? { locales: manifest.locales } : {}), + ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), + ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), installedAt: now, updatedAt: now, }; + const declaredFrameOrigins = plugin.frameOrigins ?? []; + const declaredHttpOrigins = plugin.httpOrigins ?? []; + const declaredApiPostPaths = plugin.apiPostPaths ?? []; await savePlugin(plugin, code); invalidateFrameOriginsCache(); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); - return NextResponse.json({ plugin }); + return NextResponse.json({ plugin, warnings: pkg.warnings }); } catch (error) { logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts index 6f11c7a7d..3a092fba2 100644 --- a/app/api/admin/themes/__tests__/route.test.ts +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -26,7 +26,8 @@ vi.mock('@/lib/admin/plugin-registry', () => ({ getThemeRegistry: vi.fn(async () => ({ themes: [] })), })); -import { DELETE } from '../route'; +import JSZip from 'jszip'; +import { DELETE, POST } from '../route'; function del(id: string) { return DELETE(new NextRequest('http://localhost/api/admin/themes', { @@ -48,3 +49,19 @@ describe('DELETE /api/admin/themes', () => { expect(policy.forceEnabledThemes).toEqual(['other']); }); }); + +describe('POST /api/admin/themes', () => { + it('refuses a theme.css that unpacks past the limit', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ id: 'big', name: 'Big', version: '1.0.0', author: 'a', type: 'theme', variants: ['light'] })); + zip.file('theme.css', new Uint8Array(30 * 1024 * 1024)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 't.zip')); + + const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form })); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/once unpacked/); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index a7869c0c1..8f4cfbec9 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -10,8 +10,7 @@ import { } from '@/lib/admin/plugin-registry'; import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; -import JSZip from 'jszip'; -import { MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { ExtensionPackageError, openPackage, readManifest, readText, type OpenedPackage } from '@/lib/extension-package'; import { sanitizeThemeCSS } from '@/lib/theme-loader'; /** @@ -48,41 +47,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing file' }, { status: 400 }); } - if (file.size > MAX_THEME_SIZE) { - return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 }); - } - - // Extract and validate ZIP - let zip: JSZip; - try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); - } - - // Find root - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); - } - + let pkg: OpenedPackage; let manifest: Record; try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); + pkg = await openPackage(await file.arrayBuffer(), 'theme'); + manifest = await readManifest(pkg); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; } // Validate manifest @@ -112,13 +84,17 @@ export async function POST(request: NextRequest) { } // Read theme.css - const cssFile = zip.file(root + 'theme.css'); - if (!cssFile) { + let css: string | null; + try { + css = await readText(pkg, 'theme.css'); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + if (css === null) { return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 }); } - let css = await cssFile.async('string'); - // Sanitize unconditionally: the safety check is advisory, and the // sanitizer is also what drops rules outside :root and .dark. const warnings: string[] = []; diff --git a/lib/__tests__/extension-package.test.ts b/lib/__tests__/extension-package.test.ts new file mode 100644 index 000000000..ce0945a0d --- /dev/null +++ b/lib/__tests__/extension-package.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { + checkPluginManifest, + compareVersions, + readPluginPackage, + MAX_ENTRY_BYTES, + MAX_ZIP_ENTRIES, +} from '../extension-package'; + +const BASE = { + id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function zipOf(files: Record): Promise { + const zip = new JSZip(); + for (const [name, data] of Object.entries(files)) zip.file(name, data); + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); +} + +describe('checkPluginManifest', () => { + it('accepts a normal manifest and keeps only checked fields', () => { + const { manifest, errors } = checkPluginManifest({ ...BASE, somethingElse: 1, locales: { de: { a: 'b' } } }); + + expect(errors).toEqual([]); + expect(manifest).toMatchObject({ id: 'demo-plugin', permissions: ['email:read'], locales: { de: { a: 'b' } } }); + expect(manifest).not.toHaveProperty('somethingElse'); + }); + + it('refuses permissions given as a string', () => { + // As a string, "crypto:full email:send" used to pass validation and then + // grant both through String.prototype.includes. + const { manifest, errors } = checkPluginManifest({ ...BASE, permissions: 'crypto:full email:send' }); + + expect(manifest).toBeNull(); + expect(errors.join()).toMatch(/must be a list of strings/); + }); + + it('refuses unknown permissions', () => { + expect(checkPluginManifest({ ...BASE, permissions: ['email:read', 'root:all'] }).errors.join()) + .toMatch(/Unknown permissions: root:all/); + }); + + it.each(['../index.js', '/index.js', 'a/../../x.js', 'index.css', 'a\\b.js'])('refuses entrypoint %s', (entrypoint) => { + expect(checkPluginManifest({ ...BASE, entrypoint }).manifest).toBeNull(); + }); + + it('refuses a select setting whose options are not a list', () => { + const { errors } = checkPluginManifest({ + ...BASE, + settingsSchema: { mode: { type: 'select', label: 'Mode', default: 'a', options: 'a,b' } }, + }); + + expect(errors.join()).toMatch(/settingsSchema\.mode\.options/); + }); + + it('refuses a number default outside min/max', () => { + const { errors } = checkPluginManifest({ + ...BASE, + settingsSchema: { size: { type: 'number', label: 'Size', default: 900, min: 16, max: 512 } }, + }); + + expect(errors.join()).toMatch(/outside min\/max/); + }); + + it('drops invalid origins with a warning instead of storing them', () => { + const { manifest, warnings } = checkPluginManifest({ + ...BASE, + permissions: ['http:fetch'], + httpOrigins: ['https://api.example.com', 'https://*.com', 'http://plain.example'], + }); + + expect(manifest?.httpOrigins).toEqual(['https://api.example.com']); + expect(warnings.join()).toMatch(/\*\.com/); + }); + + it('refuses a plugin that needs a newer Bulwark', () => { + expect(checkPluginManifest({ ...BASE, minAppVersion: '9.0.0' }, { appVersion: '1.9.2' }).errors.join()) + .toMatch(/Needs Bulwark 9\.0\.0/); + }); + + it('refuses a privileged plugin without crypto:full', () => { + expect(checkPluginManifest({ ...BASE, tier: 'privileged' }).manifest).toBeNull(); + }); +}); + +describe('readPluginPackage', () => { + it('reads manifest and code from a package in a sub folder', async () => { + const data = await zipOf({ 'demo/manifest.json': JSON.stringify(BASE), 'demo/index.js': 'module.exports = {}' }); + + const pkg = await readPluginPackage(data); + + expect(pkg.code).toBe('module.exports = {}'); + expect(pkg.manifest.id).toBe('demo-plugin'); + }); + + it('stops inflating a file that unpacks past the limit (ZIP bomb)', async () => { + // ~25 KB compressed, more than the per-file cap once inflated. + const data = await zipOf({ + 'manifest.json': JSON.stringify(BASE), + 'index.js': new Uint8Array(MAX_ENTRY_BYTES + 1024), + }); + expect(data.byteLength).toBeLessThan(200 * 1024); + + await expect(readPluginPackage(data)).rejects.toThrow(/larger than .* once unpacked/); + }); + + it('refuses a ZIP with too many entries', async () => { + const files: Record = { 'manifest.json': JSON.stringify(BASE), 'index.js': '' }; + for (let i = 0; i < MAX_ZIP_ENTRIES; i++) files[`f${i}.json`] = '{}'; + + await expect(readPluginPackage(await zipOf(files))).rejects.toThrow(/entries/); + }); + + it('refuses disallowed file types', async () => { + const data = await zipOf({ 'manifest.json': JSON.stringify(BASE), 'index.js': '', 'run.exe': 'MZ' }); + + await expect(readPluginPackage(data)).rejects.toThrow(/run\.exe/); + }); + + it('refuses a package whose id is not the expected one', async () => { + const data = await zipOf({ 'manifest.json': JSON.stringify(BASE), 'index.js': '' }); + + await expect(readPluginPackage(data, { expectedId: 'other' })).rejects.toThrow(/does not match/); + }); +}); + +describe('compareVersions', () => { + it('compares numerically', () => { + expect(compareVersions('1.10.0', '1.9.9')).toBe(1); + expect(compareVersions('1.2.0', '1.2.0-beta.1')).toBe(0); + expect(compareVersions('0.9.0', '1.0.0')).toBe(-1); + }); +}); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index d2376ea17..1e1a791cb 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -221,3 +221,25 @@ describe('usePluginStore', () => { }); }); }); + +describe('installPlugin', () => { + beforeEach(() => { + resetStore(); + }); + + it('keeps the translations a plugin ships', async () => { + const { extractPlugin } = await import('@/lib/plugin-validator'); + vi.mocked(extractPlugin).mockResolvedValueOnce({ + valid: true, errors: [], warnings: [], preview: null, code: 'module.exports = {}', + manifest: { + id: 'translated', name: 'T', version: '1.0.0', author: 'a', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', locales: { de: { hello: 'Hallo' } }, + }, + }); + + const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + + expect(result.success).toBe(true); + expect(usePluginStore.getState().plugins[0].locales).toEqual({ de: { hello: 'Hallo' } }); + }); +}); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 0eb5e2d95..9e02c7f1b 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -10,84 +10,15 @@ */ import { getPluginRegistry } from './plugin-registry'; +import { sanitizeFrameOrigins } from '../extension-origins'; -// `https://host`, `https://host:port`, or `https://*.host[:port]` -// -// Each label is alphanumeric with optional inner dashes; the final TLD label -// MUST start with a letter so we reject raw IPv4 literals. -// -// Disallowed by the regex (intentionally): -// - any scheme other than https -// - paths, queries, fragments -// - userinfo, IPv4 literals, IPv6 literals (`[::1]`) -// - bare wildcards (`https://*`) -const FRAME_ORIGIN_RE = - /^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i; - -export function isValidFrameOrigin(origin: unknown): origin is string { - if (typeof origin !== 'string') return false; - if (origin.length > 200) return false; - if (!FRAME_ORIGIN_RE.test(origin)) return false; - // Reject control characters / whitespace as a final safeguard against - // anything that would let an attacker break out of the directive. - if (/[\s'"`;,()]/.test(origin)) return false; - return true; -} - -/** - * Sanitises a list of candidate origins from a manifest. Drops invalid - * entries silently and dedupes (case-insensitive on the host). - */ -export function sanitizeFrameOrigins(input: unknown): string[] { - if (!Array.isArray(input)) return []; - const seen = new Set(); - const out: string[] = []; - for (const value of input) { - if (!isValidFrameOrigin(value)) continue; - const key = value.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(value); - } - return out; -} - -/** - * Same syntax + validation as `sanitizeFrameOrigins`, but for the - * `httpOrigins` manifest field. Kept as a separate exported function so the - * intent is explicit at every call site (frame embedding vs. HTTP fetch). - */ -export const sanitizeHttpOrigins = sanitizeFrameOrigins; - -// ─── apiPostPaths (manifest field) ──────────────────────────── - -/** - * Validates an `/api/...` path entry. Must start with `/api/`, contain only - * URL-path-safe characters, and have no `..` segment. The trailing slash is - * meaningful (treated as a prefix at enforcement time). - */ -export function isValidApiPostPath(path: unknown): path is string { - if (typeof path !== 'string') return false; - if (path.length === 0 || path.length > 200) return false; - if (!path.startsWith('/api/')) return false; - if (path.includes('..')) return false; - if (/[\s'"`;,()?#]/.test(path)) return false; - if (!/^[/A-Za-z0-9._-]+$/.test(path)) return false; - return true; -} - -export function sanitizeApiPostPaths(input: unknown): string[] { - if (!Array.isArray(input)) return []; - const seen = new Set(); - const out: string[] = []; - for (const value of input) { - if (!isValidApiPostPath(value)) continue; - if (seen.has(value)) continue; - seen.add(value); - out.push(value); - } - return out; -} +export { + isValidFrameOrigin, + sanitizeFrameOrigins, + sanitizeHttpOrigins, + isValidApiPostPath, + sanitizeApiPostPaths, +} from '../extension-origins'; // In-memory cache. The proxy fires on every page navigation; reading the // registry JSON every time is fine but cheap to skip when nothing has diff --git a/lib/extension-origins.ts b/lib/extension-origins.ts new file mode 100644 index 000000000..a30299ac5 --- /dev/null +++ b/lib/extension-origins.ts @@ -0,0 +1,81 @@ +// Manifest origin and path validation shared by the client install path, +// the admin and marketplace routes and the CSP builder. No server imports, +// so the browser can use it too. + +// `https://host`, `https://host:port`, or `https://*.host[:port]` +// +// Each label is alphanumeric with optional inner dashes; the final TLD label +// MUST start with a letter so we reject raw IPv4 literals. +// +// Disallowed by the regex (intentionally): +// - any scheme other than https +// - paths, queries, fragments +// - userinfo, IPv4 literals, IPv6 literals (`[::1]`) +// - bare wildcards (`https://*`) +const FRAME_ORIGIN_RE = + /^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i; + +export function isValidFrameOrigin(origin: unknown): origin is string { + if (typeof origin !== 'string') return false; + if (origin.length > 200) return false; + if (!FRAME_ORIGIN_RE.test(origin)) return false; + // Reject control characters / whitespace as a final safeguard against + // anything that would let an attacker break out of the directive. + if (/[\s'"`;,()]/.test(origin)) return false; + return true; +} + +/** + * Sanitises a list of candidate origins from a manifest. Drops invalid + * entries silently and dedupes (case-insensitive on the host). + */ +export function sanitizeFrameOrigins(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out: string[] = []; + for (const value of input) { + if (!isValidFrameOrigin(value)) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(value); + } + return out; +} + +/** + * Same syntax + validation as `sanitizeFrameOrigins`, but for the + * `httpOrigins` manifest field. Kept as a separate exported function so the + * intent is explicit at every call site (frame embedding vs. HTTP fetch). + */ +export const sanitizeHttpOrigins = sanitizeFrameOrigins; + +// ─── apiPostPaths (manifest field) ──────────────────────────── + +/** + * Validates an `/api/...` path entry. Must start with `/api/`, contain only + * URL-path-safe characters, and have no `..` segment. The trailing slash is + * meaningful (treated as a prefix at enforcement time). + */ +export function isValidApiPostPath(path: unknown): path is string { + if (typeof path !== 'string') return false; + if (path.length === 0 || path.length > 200) return false; + if (!path.startsWith('/api/')) return false; + if (path.includes('..')) return false; + if (/[\s'"`;,()?#]/.test(path)) return false; + if (!/^[/A-Za-z0-9._-]+$/.test(path)) return false; + return true; +} + +export function sanitizeApiPostPaths(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out: string[] = []; + for (const value of input) { + if (!isValidApiPostPath(value)) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} diff --git a/lib/extension-package.ts b/lib/extension-package.ts new file mode 100644 index 000000000..c2c577771 --- /dev/null +++ b/lib/extension-package.ts @@ -0,0 +1,508 @@ +// Reading and validating plugin and theme packages (ZIP + manifest.json). +// +// One implementation for every install path: the browser upload in Settings, +// the admin upload, the marketplace install and the marketplace preview. The +// copies these replace had drifted apart: some checked file types, some the +// permission list, some the origin lists, and none bounded how much a ZIP may +// inflate to. + +import JSZip from 'jszip'; +import { + ALL_PERMISSIONS, + ALLOWED_PLUGIN_FILES, + MAX_PLUGIN_SIZE, + MAX_THEME_SIZE, + type PluginManifest, + type PluginTier, + type PluginType, + type SettingFieldSchema, +} from './plugin-types'; +import { sanitizeApiPostPaths, sanitizeFrameOrigins, sanitizeHttpOrigins } from './extension-origins'; + +export const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + +/** No real plugin or theme needs more files than this. */ +export const MAX_ZIP_ENTRIES = 200; +/** Largest single file we inflate. Bundles are capped at 5 MB zipped. */ +export const MAX_ENTRY_BYTES = 20 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 512 * 1024; + +export type ExtensionKind = 'plugin' | 'theme'; + +export class ExtensionPackageError extends Error {} + +export interface OpenedPackage { + zip: JSZip; + /** Folder prefix when every entry sits inside one top-level directory. */ + root: string; +} + +function toUint8(data: ArrayBuffer | Uint8Array): Uint8Array { + return data instanceof Uint8Array ? data : new Uint8Array(data); +} + +/** + * Open a package ZIP. Checks the compressed size and entry count; files are + * only inflated when read (see `readEntry`), each under a hard cap. + */ +export async function openPackage(data: ArrayBuffer | Uint8Array, kind: ExtensionKind): Promise { + const bytes = toUint8(data); + const max = kind === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; + if (bytes.byteLength > max) { + throw new ExtensionPackageError(`${kind === 'theme' ? 'Theme' : 'Plugin'} ZIP exceeds ${Math.round(max / (1024 * 1024))} MB size limit`); + } + let zip: JSZip; + try { + zip = await JSZip.loadAsync(bytes); + } catch { + throw new ExtensionPackageError('Invalid ZIP file'); + } + const names = Object.keys(zip.files); + if (names.length > MAX_ZIP_ENTRIES) { + throw new ExtensionPackageError(`ZIP has ${names.length} entries; at most ${MAX_ZIP_ENTRIES} are allowed`); + } + return { zip, root: findRoot(zip) }; +} + +function findRoot(zip: JSZip): string { + const entries = Object.keys(zip.files); + const topDirs = new Set(entries.map((e) => e.split('/')[0])); + if (topDirs.size === 1) { + const dir = [...topDirs][0]; + if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) return dir + '/'; + } + return ''; +} + +/** + * Inflate one entry, stopping as soon as it passes `maxBytes`. The size in + * the ZIP header is whatever the author wrote, so it can't be trusted to + * stop a ZIP bomb; counting the inflated bytes can. + */ +export async function readEntry(pkg: OpenedPackage, name: string, maxBytes = MAX_ENTRY_BYTES): Promise { + const entry = pkg.zip.file(pkg.root + name); + if (!entry || entry.dir) return null; + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + let total = 0; + let done = false; + // internalStream is part of JSZip's API but missing from its typings. + const stream = (entry as unknown as { internalStream(type: 'uint8array'): JSZipStream }).internalStream('uint8array'); + stream + .on('data', (chunk: Uint8Array) => { + if (done) return; + total += chunk.byteLength; + if (total > maxBytes) { + done = true; + stream.pause(); + reject(new ExtensionPackageError(`${name} is larger than ${Math.round(maxBytes / 1024)} KB once unpacked`)); + return; + } + chunks.push(chunk); + }) + .on('error', (err: Error) => { + if (done) return; + done = true; + reject(new ExtensionPackageError(`Could not read ${name}: ${err.message}`)); + }) + .on('end', () => { + if (done) return; + done = true; + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.byteLength; + } + resolve(out); + }) + .resume(); + }); +} + +interface JSZipStream { + on(event: 'data', cb: (chunk: Uint8Array) => void): JSZipStream; + on(event: 'error', cb: (err: Error) => void): JSZipStream; + on(event: 'end', cb: () => void): JSZipStream; + pause(): JSZipStream; + resume(): JSZipStream; +} + +export async function readText(pkg: OpenedPackage, name: string, maxBytes = MAX_ENTRY_BYTES): Promise { + const bytes = await readEntry(pkg, name, maxBytes); + return bytes === null ? null : new TextDecoder('utf-8').decode(bytes); +} + +export async function readManifest(pkg: OpenedPackage): Promise> { + const raw = await readText(pkg, 'manifest.json', MAX_MANIFEST_BYTES); + if (raw === null) throw new ExtensionPackageError('Missing manifest.json'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ExtensionPackageError('Invalid manifest.json (not valid JSON)'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ExtensionPackageError('manifest.json must be a JSON object'); + } + return parsed as Record; +} + +/** Files in a plugin package whose type the host refuses. */ +export function disallowedPluginFiles(pkg: OpenedPackage): string[] { + const out: string[] = []; + for (const [path, entry] of Object.entries(pkg.zip.files)) { + if (entry.dir) continue; + const dot = path.lastIndexOf('.'); + const ext = dot >= 0 ? path.slice(dot).toLowerCase() : ''; + if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) out.push(path); + } + return out; +} + +// ─── Manifest validation ───────────────────────────────────── + +export interface ManifestCheck { + manifest: T | null; + errors: string[]; + warnings: string[]; +} + +type InstallablePluginType = Exclude; +const PLUGIN_TYPES: InstallablePluginType[] = ['ui-extension', 'sidebar-app', 'hook']; +const KNOWN_PERMISSIONS = new Set(ALL_PERMISSIONS); + +function requireString(m: Record, key: string, errors: string[], max = 200): string { + const v = m[key]; + if (typeof v !== 'string' || v.trim() === '') { + errors.push(`Missing or invalid "${key}"`); + return ''; + } + if (v.length > max) errors.push(`"${key}" is longer than ${max} characters`); + return v; +} + +function optionalString(m: Record, key: string, errors: string[], max = 2000): string | undefined { + const v = m[key]; + if (v === undefined) return undefined; + if (typeof v !== 'string') { + errors.push(`"${key}" must be a string`); + return undefined; + } + if (v.length > max) errors.push(`"${key}" is longer than ${max} characters`); + return v; +} + +/** `entrypoint` must name a .js file inside the package. */ +function isSafeEntrypoint(value: string): boolean { + if (value.startsWith('/') || value.includes('\\')) return false; + if (value.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return false; + return /\.(m?js)$/.test(value) && value.length <= 200; +} + +const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +/** Compare dotted versions numerically; pre-release tags are ignored. */ +export function compareVersions(a: string, b: string): number { + const pa = a.split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0); + const pb = b.split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + return 0; +} + +function validateSettingsSchema(raw: unknown, errors: string[]): Record | undefined { + if (raw === undefined) return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push('"settingsSchema" must be an object'); + return undefined; + } + const out: Record = {}; + for (const [key, field] of Object.entries(raw as Record)) { + const where = `settingsSchema.${key}`; + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) { + errors.push(`${where}: key must be letters, digits and _`); + continue; + } + if (!field || typeof field !== 'object' || Array.isArray(field)) { + errors.push(`${where} must be an object`); + continue; + } + const f = field as Record; + const type = f.type; + if (type !== 'boolean' && type !== 'string' && type !== 'number' && type !== 'select') { + errors.push(`${where}.type must be boolean, string, number or select`); + continue; + } + if (typeof f.label !== 'string' || !f.label) { + errors.push(`${where}.label is required`); + continue; + } + if (f.description !== undefined && typeof f.description !== 'string') { + errors.push(`${where}.description must be a string`); + continue; + } + const entry: SettingFieldSchema = { type, label: f.label, default: f.default }; + if (typeof f.description === 'string') entry.description = f.description; + if (type === 'select') { + if (!Array.isArray(f.options) || f.options.length === 0 || f.options.some((o) => typeof o !== 'string')) { + errors.push(`${where}.options must be a non-empty list of strings`); + continue; + } + entry.options = f.options as string[]; + if (!entry.options.includes(f.default as string)) { + errors.push(`${where}.default must be one of its options`); + continue; + } + } else if (type === 'boolean') { + if (typeof f.default !== 'boolean') { + errors.push(`${where}.default must be true or false`); + continue; + } + } else if (type === 'string') { + if (typeof f.default !== 'string') { + errors.push(`${where}.default must be a string`); + continue; + } + } else if (type === 'number') { + for (const bound of ['min', 'max'] as const) { + if (f[bound] !== undefined && (typeof f[bound] !== 'number' || !Number.isFinite(f[bound]))) { + errors.push(`${where}.${bound} must be a number`); + } else if (typeof f[bound] === 'number') { + entry[bound] = f[bound] as number; + } + } + if (typeof f.default !== 'number' || !Number.isFinite(f.default)) { + errors.push(`${where}.default must be a number`); + continue; + } + if ((entry.min !== undefined && f.default < entry.min) || (entry.max !== undefined && f.default > entry.max)) { + errors.push(`${where}.default is outside min/max`); + continue; + } + } + out[key] = entry; + } + return out; +} + +const CONFIG_FIELD_TYPES = new Set(['string', 'secret', 'boolean', 'number', 'select']); + +/** Admin-level setting a plugin declares (`configSchema`); mirrors PluginConfigField. */ +export interface ConfigFieldSchema { + type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; + label: string; + description?: string; + required?: boolean; + default?: unknown; + placeholder?: string; + options?: { label: string; value: string }[]; +} + +function validateConfigSchema(raw: unknown, errors: string[]): Record | undefined { + if (raw === undefined) return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push('"configSchema" must be an object'); + return undefined; + } + const out: Record = {}; + for (const [key, field] of Object.entries(raw as Record)) { + const where = `configSchema.${key}`; + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) { + errors.push(`${where}: key must be letters, digits and _`); + continue; + } + const f = field as Record | null; + if (!f || typeof f !== 'object' || Array.isArray(f) || !CONFIG_FIELD_TYPES.has(f.type as string) || typeof f.label !== 'string') { + errors.push(`${where} needs a type (string, secret, boolean, number, select) and a label`); + continue; + } + if (f.type === 'select' && (!Array.isArray(f.options) || f.options.some((o) => !o || typeof (o as { value?: unknown }).value !== 'string'))) { + errors.push(`${where}.options must be a list of { label, value }`); + continue; + } + out[key] = f as unknown as ConfigFieldSchema; + } + return out; +} + +const MAX_LOCALES = 64; +const MAX_LOCALE_KEYS = 2000; + +function validateLocales(raw: unknown, errors: string[]): Record> | undefined { + if (raw === undefined) return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push('"locales" must be an object of locale → { key: text }'); + return undefined; + } + const entries = Object.entries(raw as Record); + if (entries.length > MAX_LOCALES) { + errors.push(`"locales" has more than ${MAX_LOCALES} languages`); + return undefined; + } + const out: Record> = {}; + for (const [locale, table] of entries) { + if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale)) { + errors.push(`locales: "${locale}" is not a language tag`); + continue; + } + if (!table || typeof table !== 'object' || Array.isArray(table)) { + errors.push(`locales.${locale} must be an object`); + continue; + } + const strings = Object.entries(table as Record); + if (strings.length > MAX_LOCALE_KEYS || strings.some(([, v]) => typeof v !== 'string')) { + errors.push(`locales.${locale} must map at most ${MAX_LOCALE_KEYS} keys to strings`); + continue; + } + out[locale] = table as Record; + } + return out; +} + +function originList( + m: Record, + key: 'frameOrigins' | 'httpOrigins' | 'apiPostPaths', + sanitize: (v: unknown) => string[], + errors: string[], + warnings: string[], +): string[] | undefined { + const raw = m[key]; + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) { + errors.push(`"${key}" must be a list`); + return undefined; + } + const kept = sanitize(raw); + const dropped = raw.filter((v) => typeof v !== 'string' || !kept.includes(v)); + if (dropped.length > 0) warnings.push(`Ignored invalid ${key}: ${dropped.map(String).join(', ')}`); + return kept.length > 0 ? kept : undefined; +} + +/** What every install path stores for a plugin, after validation. */ +export type ValidatedPluginManifest = PluginManifest & { + frameOrigins?: string[]; + configSchema?: Record; +}; + +/** + * Validate a plugin manifest. Returns a copy holding only known, checked + * fields, so nothing the host didn't validate is ever stored. + */ +export function checkPluginManifest(m: Record, opts: { appVersion?: string } = {}): ManifestCheck { + const errors: string[] = []; + const warnings: string[] = []; + + const id = requireString(m, 'id', errors, 64); + if (id && !EXTENSION_ID_RE.test(id)) errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); + const name = requireString(m, 'name', errors, 100); + const version = requireString(m, 'version', errors, 64); + if (version && !VERSION_RE.test(version)) errors.push('"version" must look like 1.2.3'); + const author = requireString(m, 'author', errors, 200); + const description = optionalString(m, 'description', errors) ?? ''; + + const type = m.type as InstallablePluginType; + if (!PLUGIN_TYPES.includes(type)) errors.push(`Invalid type "${String(m.type)}". Must be one of: ${PLUGIN_TYPES.join(', ')}`); + + let tier: PluginTier | undefined; + if (m.tier !== undefined) { + if (m.tier !== 'untrusted' && m.tier !== 'privileged') errors.push('"tier" must be "untrusted" or "privileged"'); + else tier = m.tier; + } + + const entrypoint = typeof m.entrypoint === 'string' && m.entrypoint ? m.entrypoint : 'index.js'; + if (!isSafeEntrypoint(entrypoint)) errors.push('"entrypoint" must be a .js file inside the package'); + + // A string here used to slip past every check and then match by substring + // ("crypto:full email:send" granted both). + let permissions: string[] = []; + if (m.permissions !== undefined) { + if (!Array.isArray(m.permissions) || m.permissions.some((p) => typeof p !== 'string')) { + errors.push('"permissions" must be a list of strings'); + } else { + permissions = [...new Set(m.permissions as string[])]; + const unknown = permissions.filter((p) => !KNOWN_PERMISSIONS.has(p)); + if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')} (this Bulwark version does not offer them)`); + } + } + if (tier === 'privileged' && !permissions.includes('crypto:full')) { + errors.push('A privileged plugin must request the "crypto:full" permission'); + } + + const minAppVersion = optionalString(m, 'minAppVersion', errors, 32); + if (minAppVersion !== undefined && !VERSION_RE.test(minAppVersion)) errors.push('"minAppVersion" must look like 1.2.3'); + if (minAppVersion && opts.appVersion && VERSION_RE.test(opts.appVersion) && compareVersions(opts.appVersion, minAppVersion) < 0) { + errors.push(`Needs Bulwark ${minAppVersion} or later (this is ${opts.appVersion})`); + } + + const settingsSchema = validateSettingsSchema(m.settingsSchema, errors); + const configSchema = validateConfigSchema(m.configSchema, errors); + const locales = validateLocales(m.locales, errors); + const frameOrigins = originList(m, 'frameOrigins', sanitizeFrameOrigins, errors, warnings); + const httpOrigins = originList(m, 'httpOrigins', sanitizeHttpOrigins, errors, warnings); + const apiPostPaths = originList(m, 'apiPostPaths', sanitizeApiPostPaths, errors, warnings); + + if (errors.length > 0) return { manifest: null, errors, warnings }; + + const manifest: ValidatedPluginManifest = { + id, name, version, author, description, type, permissions, entrypoint, + ...(tier ? { tier } : {}), + ...(minAppVersion ? { minAppVersion } : {}), + ...(settingsSchema && Object.keys(settingsSchema).length ? { settingsSchema } : {}), + ...(configSchema && Object.keys(configSchema).length ? { configSchema } : {}), + ...(locales && Object.keys(locales).length ? { locales } : {}), + ...(frameOrigins ? { frameOrigins } : {}), + ...(httpOrigins ? { httpOrigins } : {}), + ...(apiPostPaths ? { apiPostPaths } : {}), + }; + return { manifest, errors, warnings }; +} + +// ─── Plugin code checks ────────────────────────────────────── + +const SUSPICIOUS_JS_PATTERNS = [ + { pattern: /\beval\s*\(/, label: 'eval()' }, + { pattern: /\bnew\s+Function\s*\(/, label: 'new Function()' }, + { pattern: /document\.cookie/, label: 'document.cookie' }, + { pattern: /document\.write/, label: 'document.write' }, + { pattern: /innerHTML\s*=/, label: 'innerHTML assignment' }, +]; + +/** Patterns worth a reviewer's second look. Not a security boundary: the sandbox is. */ +export function suspiciousCode(code: string): string[] { + return SUSPICIOUS_JS_PATTERNS.filter(({ pattern }) => pattern.test(code)).map(({ label }) => label); +} + +export interface PluginPackage { + manifest: ValidatedPluginManifest; + code: string; + warnings: string[]; + /** The opened ZIP, for callers that read more files (preview image). */ + pkg: OpenedPackage; +} + +/** + * Open, validate and read a plugin package. Throws ExtensionPackageError + * with every problem found when it is not installable. + */ +export async function readPluginPackage( + data: ArrayBuffer | Uint8Array, + opts: { appVersion?: string; expectedId?: string } = {}, +): Promise { + const pkg = await openPackage(data, 'plugin'); + const raw = await readManifest(pkg); + const check = checkPluginManifest(raw, opts); + const errors = [...check.errors]; + const bad = disallowedPluginFiles(pkg); + if (bad.length > 0) errors.push(`Disallowed file types: ${bad.join(', ')}`); + if (check.manifest && opts.expectedId !== undefined && check.manifest.id !== opts.expectedId) { + errors.push(`Package id "${check.manifest.id}" does not match "${opts.expectedId}"`); + } + if (errors.length > 0 || !check.manifest) throw new ExtensionPackageError(errors.join('; ')); + const code = await readText(pkg, check.manifest.entrypoint); + if (code === null) throw new ExtensionPackageError(`Missing entrypoint file: ${check.manifest.entrypoint}`); + const warnings = [...check.warnings, ...suspiciousCode(code).map((l) => `Contains ${l} - review for security`)]; + return { manifest: check.manifest, code, warnings, pkg }; +} diff --git a/lib/plugin-validator.ts b/lib/plugin-validator.ts index c37210f58..83bb7d06c 100644 --- a/lib/plugin-validator.ts +++ b/lib/plugin-validator.ts @@ -1,18 +1,21 @@ // Plugin/Theme ZIP upload validation, extraction, and manifest parsing -import JSZip from 'jszip'; import { type ThemeManifest, type PluginManifest, - type PluginType, - ALL_PERMISSIONS, - MAX_PLUGIN_SIZE, - MAX_THEME_SIZE, MAX_THEME_SKIN_BYTES, - ALLOWED_PLUGIN_FILES, } from './plugin-types'; import { sanitizeThemeCSS, sanitizeSkinCSS } from './theme-loader'; import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; +import { + ExtensionPackageError, + openPackage, + readEntry, + readManifest, + readPluginPackage, + readText, + type OpenedPackage, +} from './extension-package'; export interface ValidationResult { valid: boolean; @@ -101,83 +104,8 @@ function validateThemeManifest(manifest: Record): { result: The }; } -function validatePluginManifest(manifest: Record): { result: PluginManifest | null; errors: string[] } { - const errors = validateBaseManifest(manifest); - - const validTypes: PluginType[] = ['ui-extension', 'sidebar-app', 'hook']; - if (!validTypes.includes(manifest.type as PluginType)) { - errors.push(`Invalid type "${manifest.type}". Must be one of: ${validTypes.join(', ')}`); - } - - if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') { - errors.push('Missing or invalid "entrypoint"'); - } - - if (manifest.permissions && Array.isArray(manifest.permissions)) { - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); - if (unknown.length > 0) { - errors.push(`Unknown permissions: ${unknown.join(', ')}`); - } - } - - if (errors.length > 0) return { result: null, errors }; - - return { - result: { - ...(manifest as unknown as PluginManifest), - permissions: (manifest.permissions as string[]) || [], - }, - errors: [], - }; -} - -// ─── JS Security Checks ───────────────────────────────────── - -const SUSPICIOUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, -]; - -function checkJSSecurity(code: string): string[] { - const warnings: string[] = []; - for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { - if (pattern.test(code)) { - warnings.push(`Contains ${label} - review for security`); - } - pattern.lastIndex = 0; // reset regex - } - return warnings; -} - // ─── ZIP Extraction ────────────────────────────────────────── -function getExtension(filename: string): string { - const dot = filename.lastIndexOf('.'); - return dot >= 0 ? filename.slice(dot).toLowerCase() : ''; -} - -/** - * Find the root of the ZIP contents. - * Some ZIPs have all files inside a single top-level folder. - */ -function findZipRoot(zip: JSZip): string { - const entries = Object.keys(zip.files); - // Check if all entries share a common top-level directory - const topDirs = new Set(entries.map(e => e.split('/')[0])); - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - // Verify it's actually a directory (has entries inside it) - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - return dir + '/'; - } - } - return ''; -} - /** * Extract and validate a theme ZIP file. */ @@ -185,38 +113,14 @@ export async function extractTheme(file: File): Promise { const errors: string[] = []; const warnings: string[] = []; - // Size check - if (file.size > MAX_THEME_SIZE) { - return { - valid: false, - errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`], - warnings: [], - manifest: null, css: '', skin: null, preview: null, - }; - } - - let zip: JSZip; - try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null }; - } - - const root = findZipRoot(zip); - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null }; - } - + let pkg: OpenedPackage; let manifestData: Record; try { - const raw = await manifestFile.async('string'); - manifestData = JSON.parse(raw); - } catch { - return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null }; + pkg = await openPackage(await file.arrayBuffer(), 'theme'); + manifestData = await readManifest(pkg); + } catch (err) { + const message = err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file'; + return { valid: false, errors: [message], warnings: [], manifest: null, css: '', skin: null, preview: null }; } const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData); @@ -228,12 +132,20 @@ export async function extractTheme(file: File): Promise { // Read theme.css - required for v1 themes, optional when the manifest // declares Theme API v2 fields (tokens/extends/derive/density/radii/typography), // since the compiler can produce CSS purely from the manifest. - const cssFile = zip.file(root + 'theme.css'); const isAdvanced = isAdvancedManifest(manifest); + let cssText: string | null; + let skinText: string | null; + try { + cssText = await readText(pkg, 'theme.css'); + skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4); + } catch (err) { + const message = err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'; + return { valid: false, errors: [message], warnings, manifest, css: '', skin: null, preview: null }; + } let userCSS = ''; - if (cssFile) { - userCSS = await cssFile.async('string'); + if (cssText !== null) { + userCSS = cssText; // Sanitize instead of rejecting, and always: the sanitizer is also what // drops rules outside :root and .dark. const sanitized = sanitizeThemeCSS(userCSS); @@ -265,14 +177,11 @@ export async function extractTheme(file: File): Promise { // Read preview image if present let preview: string | null = null; if (manifest.preview) { - const previewFile = zip.file(root + manifest.preview); - if (previewFile) { - try { - const blob = await previewFile.async('blob'); - preview = await blobToDataUri(blob); - } catch { - warnings.push('Could not read preview image'); - } + try { + const bytes = await readEntry(pkg, manifest.preview, 512 * 1024); + if (bytes) preview = await blobToDataUri(new Blob([bytes as BlobPart], { type: previewType(manifest.preview) })); + } catch { + warnings.push('Could not read preview image'); } } @@ -280,12 +189,11 @@ export async function extractTheme(file: File): Promise { // component selectors and bypass the strict :root/.dark selector check - // they still go through the dangerous-pattern sanitizer. let skin: string | null = null; - const skinFile = zip.file(root + 'skin.css'); - if (skinFile) { + if (skinText !== null) { if (!isAdvanced) { warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin'); } else { - const rawSkin = await skinFile.async('string'); + const rawSkin = skinText; if (rawSkin.length > MAX_THEME_SKIN_BYTES) { warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`); } else { @@ -308,87 +216,45 @@ export async function extractTheme(file: File): Promise { } /** - * Extract and validate a plugin ZIP file. + * Extract and validate a plugin ZIP file. The checks are the ones every + * server install path runs too (see extension-package.ts). */ export async function extractPlugin(file: File): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - if (file.size > MAX_PLUGIN_SIZE) { - return { valid: false, errors: ['Plugin ZIP exceeds 5 MB size limit'], warnings: [], manifest: null, code: '', preview: null }; - } - - let zip: JSZip; + let result: Awaited>; try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, code: '', preview: null }; + result = await readPluginPackage(await file.arrayBuffer(), { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + }); + } catch (err) { + const message = err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file'; + return { valid: false, errors: message.split('; '), warnings: [], manifest: null, code: '', preview: null }; } + const warnings = [...result.warnings]; - const root = findZipRoot(zip); - - // Check for disallowed file extensions - for (const [path, entry] of Object.entries(zip.files)) { - if (entry.dir) continue; - const ext = getExtension(path); - if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { - errors.push(`Disallowed file type: ${path} (${ext})`); - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return { valid: false, errors: ['Missing manifest.json', ...errors], warnings, manifest: null, code: '', preview: null }; - } - - let manifestData: Record; - try { - const raw = await manifestFile.async('string'); - manifestData = JSON.parse(raw); - } catch { - return { valid: false, errors: ['Invalid manifest.json (not valid JSON)', ...errors], warnings, manifest: null, code: '', preview: null }; - } - - const { result: manifest, errors: manifestErrors } = validatePluginManifest(manifestData); - errors.push(...manifestErrors); - if (!manifest) { - return { valid: false, errors, warnings, manifest: null, code: '', preview: null }; - } - - // Read entrypoint - const entryFile = zip.file(root + manifest.entrypoint); - if (!entryFile) { - errors.push(`Missing entrypoint file: ${manifest.entrypoint}`); - return { valid: false, errors, warnings, manifest, code: '', preview: null }; - } - - const code = await entryFile.async('string'); - - // JS security checks - warnings.push(...checkJSSecurity(code)); - - // Read preview if present let preview: string | null = null; - const previewFile = zip.file(root + 'preview.png') || zip.file(root + 'preview.svg'); - if (previewFile) { + const previewName = result.pkg.zip.file(result.pkg.root + 'preview.png') ? 'preview.png' + : result.pkg.zip.file(result.pkg.root + 'preview.svg') ? 'preview.svg' : null; + if (previewName) { try { - const blob = await previewFile.async('blob'); - preview = await blobToDataUri(blob); + const bytes = await readEntry(result.pkg, previewName, 512 * 1024); + if (bytes) { + const type = previewName.endsWith('.svg') ? 'image/svg+xml' : 'image/png'; + preview = await blobToDataUri(new Blob([bytes as BlobPart], { type })); + } } catch { warnings.push('Could not read preview image'); } } - return { - valid: errors.length === 0, - errors, - warnings, - manifest, - code, - preview, - }; + return { valid: true, errors: [], warnings, manifest: result.manifest, code: result.code, preview }; +} + +function previewType(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith('.svg')) return 'image/svg+xml'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + if (lower.endsWith('.webp')) return 'image/webp'; + return 'image/png'; } function blobToDataUri(blob: Blob): Promise { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 078c36491..2686a8b7a 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -88,6 +88,7 @@ export const usePluginStore = create()( adminApproved: false, // Requires admin approval before it can be enabled settings: existing?.settings ?? {}, settingsSchema: manifest.settingsSchema, + ...(manifest.locales ? { locales: manifest.locales } : {}), ...(bundleHash ? { bundleHash } : {}), ...(manifest.httpOrigins && manifest.httpOrigins.length > 0 ? { httpOrigins: manifest.httpOrigins } From e9f87c7970dc7aec711480e7f28e68a8cb25573e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:47:58 +0200 Subject: [PATCH 006/147] fix: install Theme API v2 themes and their skins from the admin page and the marketplace --- app/api/admin/marketplace/route.ts | 51 ++--- app/api/admin/themes/[id]/css/route.ts | 43 +---- app/api/admin/themes/[id]/skin/route.ts | 13 ++ app/api/admin/themes/__tests__/route.test.ts | 25 ++- app/api/admin/themes/route.ts | 77 ++------ app/api/plugins/route.ts | 2 + lib/__tests__/theme-store-sync.test.ts | 36 +++- lib/admin/plugin-registry.ts | 50 ++++- lib/admin/theme-asset.ts | 39 ++++ lib/extension-package.ts | 158 ++++++++++++++++ lib/plugin-types.ts | 2 + lib/plugin-validator.ts | 185 ++----------------- stores/theme-store.ts | 35 +++- 13 files changed, 394 insertions(+), 322 deletions(-) create mode 100644 app/api/admin/themes/[id]/skin/route.ts create mode 100644 lib/admin/theme-asset.ts diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index f5659edff..d83e156b8 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -9,6 +9,7 @@ import { getTheme, getPluginRegistry, getThemeRegistry, + themeCSSResolver, type ServerPlugin, type ServerTheme, } from '@/lib/admin/plugin-registry'; @@ -16,13 +17,10 @@ import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { EXTENSION_ID_RE, ExtensionPackageError, - openPackage, - readManifest, readPluginPackage, - readText, + readThemePackage, suspiciousCode, } from '@/lib/extension-package'; -import { sanitizeThemeCSS } from '@/lib/theme-loader'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -151,59 +149,38 @@ export async function POST(request: NextRequest) { const now = new Date().toISOString(); if (type === 'theme') { - let pkg; - let manifest: Record; - try { - pkg = await openPackage(buffer, 'theme'); - manifest = await readManifest(pkg); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; + const read = await readThemePackage(buffer, { resolveExtends: await themeCSSResolver() }); + if (!read.valid || !read.manifest) { + return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 }); } + const manifest = read.manifest; // The slug is what the admin picked and what install status is keyed // on. A bundle that names another id would install over whatever // already uses that id, keeping its enabled and force-enabled state. - if (manifest.id !== undefined && manifest.id !== slug) { + if (manifest.id !== slug) { return NextResponse.json( { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, { status: 400 }, ); } const resolvedId = slug; - - let css: string | null; - try { - css = await readText(pkg, 'theme.css'); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; - } - if (css === null) { - return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); - } - - // Sanitize unconditionally: the safety check is advisory, and the - // sanitizer is also what drops rules outside :root and .dark. - const warnings: string[] = []; - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); + const { css, skin, warnings } = read; const existingTheme = await getTheme(resolvedId); const isUpdate = existingTheme !== null; const theme: ServerTheme = { id: resolvedId, - name: (manifest.name as string) || slug, + name: manifest.name || slug, // Prefer the directory-published version (what we requested) over // manifest.version. Publishers sometimes forget to bump the version // inside the bundle's manifest.json; trusting it would make the // update never appear to "stick" — the registry would keep showing // the older version even after a successful update. - version: version || (manifest.version as string), - author: (manifest.author as string) || 'Unknown', - description: (manifest.description as string) || '', - variants: (manifest.variants as string[]) || ['light', 'dark'], + version: version || manifest.version, + author: manifest.author || 'Unknown', + description: manifest.description || '', + variants: manifest.variants, enabled: existingTheme?.enabled ?? true, ...(existingTheme?.forceEnabled !== undefined ? { forceEnabled: existingTheme.forceEnabled } @@ -212,7 +189,7 @@ export async function POST(request: NextRequest) { updatedAt: now, }; - await saveTheme(theme, css); + await saveTheme(theme, css, skin); await auditLog( isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme', { diff --git a/app/api/admin/themes/[id]/css/route.ts b/app/api/admin/themes/[id]/css/route.ts index 347ccfaad..ad154a470 100644 --- a/app/api/admin/themes/[id]/css/route.ts +++ b/app/api/admin/themes/[id]/css/route.ts @@ -1,46 +1,13 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry'; -import { logger } from '@/lib/logger'; +import { NextRequest } from 'next/server'; +import { serveThemeAsset } from '@/lib/admin/theme-asset'; /** - * GET /api/admin/themes/[id]/css - Serve theme CSS to clients + * GET /api/admin/themes/[id]/css - Serve a theme's CSS to clients */ export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - - // Validate ID format - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { - return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 }); - } - - // Verify theme exists and is enabled - const registry = await getThemeRegistry(); - const theme = registry.themes.find(t => t.id === id); - if (!theme) { - return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); - } - if (!theme.enabled) { - return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 }); - } - - const css = await getThemeCSS(id); - if (!css) { - return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 }); - } - - return new NextResponse(css, { - headers: { - 'Content-Type': 'text/css; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', - 'X-Content-Type-Options': 'nosniff', - }, - }); - } catch (error) { - logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } + const { id } = await params; + return serveThemeAsset(id, 'css'); } diff --git a/app/api/admin/themes/[id]/skin/route.ts b/app/api/admin/themes/[id]/skin/route.ts new file mode 100644 index 000000000..c292e44e5 --- /dev/null +++ b/app/api/admin/themes/[id]/skin/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server'; +import { serveThemeAsset } from '@/lib/admin/theme-asset'; + +/** + * GET /api/admin/themes/[id]/skin - Serve a theme's skin (Theme API v2 component CSS) to clients + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + return serveThemeAsset(id, 'skin'); +} diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts index 3a092fba2..89a60618a 100644 --- a/app/api/admin/themes/__tests__/route.test.ts +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -19,9 +19,11 @@ vi.mock('@/lib/admin/config-manager', () => ({ setPolicy: vi.fn(async (p: Record) => { policy = p; }), }, })); +const saved: Array<{ theme: Record; css: string; skin: string | null }> = []; vi.mock('@/lib/admin/plugin-registry', () => ({ deleteTheme: vi.fn(async () => true), - saveTheme: vi.fn(), + themeCSSResolver: vi.fn(async () => () => null), + saveTheme: vi.fn(async (theme: Record, css: string, skin: string | null) => { saved.push({ theme, css, skin }); }), getTheme: vi.fn(async () => null), getThemeRegistry: vi.fn(async () => ({ themes: [] })), })); @@ -38,6 +40,7 @@ function del(id: string) { } beforeEach(() => { + saved.length = 0; policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] }; }); @@ -65,3 +68,23 @@ describe('POST /api/admin/themes', () => { expect((await res.json()).error).toMatch(/once unpacked/); }); }); + +describe('POST /api/admin/themes with Theme API v2', () => { + it('installs a tokens-only theme with a skin', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', type: 'theme', variants: ['light', 'dark'], + apiVersion: 2, tokens: { light: { '--color-primary': '#0066cc' }, dark: { '--color-primary': '#66aaff' } }, + })); + zip.file('skin.css', '.message-list { border-radius: 8px; }'); + const bytes = await zip.generateAsync({ type: 'uint8array' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'ocean.zip')); + + const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form })); + + expect(res.status).toBe(200); + expect(saved[0].css).toContain('#0066cc'); + expect(saved[0].skin).toContain('border-radius'); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 8f4cfbec9..9f07bf34f 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -6,12 +6,12 @@ import { getThemeRegistry, saveTheme, deleteTheme as removeTheme, + themeCSSResolver, type ServerTheme, } from '@/lib/admin/plugin-registry'; import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; -import { ExtensionPackageError, openPackage, readManifest, readText, type OpenedPackage } from '@/lib/extension-package'; -import { sanitizeThemeCSS } from '@/lib/theme-loader'; +import { readThemePackage } from '@/lib/extension-package'; /** * GET /api/admin/themes - List all admin-managed themes @@ -47,75 +47,28 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing file' }, { status: 400 }); } - let pkg: OpenedPackage; - let manifest: Record; - try { - pkg = await openPackage(await file.arrayBuffer(), 'theme'); - manifest = await readManifest(pkg); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; + const read = await readThemePackage(await file.arrayBuffer(), { + resolveExtends: await themeCSSResolver(), + }); + if (!read.valid || !read.manifest) { + return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 }); } - - // Validate manifest - const errors: string[] = []; - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - - if (manifest.type !== 'theme') { - errors.push(`Expected type "theme", got "${manifest.type}"`); - } - - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { - errors.push('Missing or empty "variants" array'); - } else { - const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); - if (!valid) errors.push('Variants must be "light" or "dark"'); - } - - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Read theme.css - let css: string | null; - try { - css = await readText(pkg, 'theme.css'); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; - } - if (css === null) { - return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 }); - } - - // Sanitize unconditionally: the safety check is advisory, and the - // sanitizer is also what drops rules outside :root and .dark. - const warnings: string[] = []; - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); + const { manifest, css, skin, warnings } = read; const now = new Date().toISOString(); const theme: ServerTheme = { - id: manifest.id as string, - name: manifest.name as string, - version: manifest.version as string, - author: manifest.author as string, - description: (manifest.description as string) || '', - variants: manifest.variants as string[], + id: manifest.id, + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description || '', + variants: manifest.variants, enabled: true, installedAt: now, updatedAt: now, }; - await saveTheme(theme, css); + await saveTheme(theme, css, skin); await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip); return NextResponse.json({ theme, warnings }); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 8332d1da9..197117906 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -82,6 +82,8 @@ export async function GET(request: NextRequest) { description: t.description, variants: t.variants, forceEnabled: t.forceEnabled || false, + hasSkin: t.hasSkin || false, + contentHash: t.contentHash, })); return NextResponse.json( diff --git a/lib/__tests__/theme-store-sync.test.ts b/lib/__tests__/theme-store-sync.test.ts index 824fc059f..4486c6409 100644 --- a/lib/__tests__/theme-store-sync.test.ts +++ b/lib/__tests__/theme-store-sync.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const serverThemes: { themes: unknown[] } = { themes: [] }; +const assets: Record = {}; vi.mock('@/lib/browser-navigation', async (orig) => ({ ...(await orig()), - apiFetch: vi.fn(async () => new Response(JSON.stringify({ plugins: [], ...serverThemes }))), + apiFetch: vi.fn(async (url: string) => { + const path = url.split('?')[0]; + if (path === '/api/plugins') return new Response(JSON.stringify({ plugins: [], ...serverThemes })); + return path in assets ? new Response(assets[path]) : new Response('missing', { status: 404 }); + }), })); vi.mock('@/lib/plugin-storage', () => ({ pluginStorage: { @@ -13,6 +18,7 @@ vi.mock('@/lib/plugin-storage', () => ({ deleteThemeSkin: vi.fn(async () => {}), deletePreview: vi.fn(async () => {}), getThemeSkin: vi.fn(async () => null), + saveThemeSkin: vi.fn(async () => {}), }, })); @@ -60,3 +66,31 @@ describe('server theme sync', () => { expect(useThemeStore.getState().installedThemes.some((t) => t.id === 'corp')).toBe(true); }); }); + +describe('server theme sync with skins', () => { + it('downloads the skin of a v2 theme', async () => { + useThemeStore.setState((s) => ({ installedThemes: s.installedThemes.filter((t) => t.builtIn), activeThemeId: null })); + serverThemes.themes = [{ id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', description: '', variants: ['light'], forceEnabled: false, hasSkin: true, contentHash: 'h1' }]; + assets['/api/admin/themes/ocean/css'] = ':root{--color-primary:#0066cc}'; + assets['/api/admin/themes/ocean/skin'] = '.message-list{border-radius:8px}'; + + await useThemeStore.getState().syncServerThemes(); + + const ocean = useThemeStore.getState().installedThemes.find((t) => t.id === 'ocean'); + expect(ocean?.skin).toContain('border-radius'); + expect(pluginStorage.saveThemeSkin).toHaveBeenCalledWith('ocean', expect.stringContaining('border-radius')); + }); + + it('re-downloads a re-upload of the same version', async () => { + useThemeStore.setState((s) => ({ + installedThemes: [...s.installedThemes.filter((t) => t.builtIn), { ...CORP, id: 'ocean', forceEnabled: false, version: '1.0.0', contentHash: 'h1', css: ':root{--color-primary:red}' }], + activeThemeId: null, + })); + serverThemes.themes = [{ id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', description: '', variants: ['light'], forceEnabled: false, hasSkin: false, contentHash: 'h2' }]; + assets['/api/admin/themes/ocean/css'] = ':root{--color-primary:blue}'; + + await useThemeStore.getState().syncServerThemes(); + + expect(useThemeStore.getState().installedThemes.find((t) => t.id === 'ocean')?.css).toContain('blue'); + }); +}); diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 1ec02385a..49bc072d7 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -102,6 +102,13 @@ export interface ServerTheme { forceEnabled?: boolean; installedAt: string; updatedAt: string; + /** True when a Theme API v2 skin.css is stored next to the CSS. */ + hasSkin?: boolean; + /** + * SHA-256 of the CSS and skin. Clients use it to notice a re-upload of + * the same version and as a cache key on the CSS and skin URLs. + */ + contentHash?: string; } interface PluginRegistry { @@ -274,6 +281,7 @@ export async function getTheme(id: string): Promise { export async function saveTheme( theme: ServerTheme, css: string, + skin: string | null = null, ): Promise { assertWritable('install theme'); if (!SAFE_ID_RE.test(theme.id)) { @@ -285,6 +293,17 @@ export async function saveTheme( // Save CSS file const cssPath = path.join(dir, `${theme.id}.css`); await writeFile(cssPath, css, 'utf-8'); + const skinPath = path.join(dir, `${theme.id}.skin.css`); + if (skin) { + await writeFile(skinPath, skin, 'utf-8'); + } else { + try { await unlink(skinPath); } catch { /* none stored */ } + } + theme = { + ...theme, + hasSkin: !!skin, + contentHash: createHash('sha256').update(css).update('\0').update(skin ?? '').digest('hex'), + }; // Update registry const registry = await getThemeRegistry(); @@ -317,13 +336,38 @@ export async function deleteTheme(id: string): Promise { registry.themes.splice(idx, 1); await writeJsonFile(themeRegistryPath(), registry); - // Remove CSS file - const cssPath = path.join(getThemesDir(), `${id}.css`); - try { await unlink(cssPath); } catch { /* ok if missing */ } + // Remove CSS and skin files + for (const file of [`${id}.css`, `${id}.skin.css`]) { + try { await unlink(path.join(getThemesDir(), file)); } catch { /* ok if missing */ } + } return true; } +export async function getThemeSkin(id: string): Promise { + if (!SAFE_ID_RE.test(id)) return null; + try { + return await readFile(path.join(getThemesDir(), `${id}.skin.css`), 'utf-8'); + } catch { + return null; + } +} + +/** + * CSS of every installed theme and every built-in theme, keyed by id, for + * resolving a new theme's `extends`. + */ +export async function themeCSSResolver(): Promise<(id: string) => string | null> { + const { BUILTIN_THEMES } = await import('@/lib/builtin-themes'); + const map = new Map(); + for (const t of BUILTIN_THEMES) if (t.css) map.set(t.id, t.css); + for (const t of (await getThemeRegistry()).themes) { + const css = await getThemeCSS(t.id); + if (css) map.set(t.id, css); + } + return (id) => map.get(id) ?? null; +} + export async function getThemeCSS(id: string): Promise { const cssPath = path.join(getThemesDir(), `${id}.css`); try { diff --git a/lib/admin/theme-asset.ts b/lib/admin/theme-asset.ts new file mode 100644 index 000000000..9dc700840 --- /dev/null +++ b/lib/admin/theme-asset.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { getThemeCSS, getThemeRegistry, getThemeSkin } from './plugin-registry'; +import { logger } from '@/lib/logger'; + +/** + * Serve an enabled theme's CSS or skin. Clients put the theme's content hash + * in the URL, so the response may be revalidated freely but never served + * stale after an update (it used to be cached publicly for an hour). + */ +export async function serveThemeAsset(id: string, kind: 'css' | 'skin'): Promise { + try { + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { + return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 }); + } + const registry = await getThemeRegistry(); + const theme = registry.themes.find(t => t.id === id); + if (!theme) { + return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); + } + if (!theme.enabled) { + return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 }); + } + const body = kind === 'css' ? await getThemeCSS(id) : await getThemeSkin(id); + if (!body) { + return NextResponse.json({ error: kind === 'css' ? 'Theme CSS not found' : 'Theme has no skin' }, { status: 404 }); + } + return new NextResponse(body, { + headers: { + 'Content-Type': 'text/css; charset=utf-8', + 'Cache-Control': 'private, no-cache', + ...(theme.contentHash ? { ETag: `"${theme.contentHash}"` } : {}), + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch (error) { + logger.error('Theme asset serve error', { kind, error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/lib/extension-package.ts b/lib/extension-package.ts index c2c577771..895abafc2 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -16,7 +16,11 @@ import { type PluginTier, type PluginType, type SettingFieldSchema, + type ThemeManifest, + MAX_THEME_SKIN_BYTES, } from './plugin-types'; +import { sanitizeSkinCSS, sanitizeThemeCSS } from './theme-loader'; +import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; import { sanitizeApiPostPaths, sanitizeFrameOrigins, sanitizeHttpOrigins } from './extension-origins'; export const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; @@ -506,3 +510,157 @@ export async function readPluginPackage( const warnings = [...check.warnings, ...suspiciousCode(code).map((l) => `Contains ${l} - review for security`)]; return { manifest: check.manifest, code, warnings, pkg }; } + +// ─── Themes ────────────────────────────────────────────────── + +function validateBaseManifest(manifest: Record): string[] { + const errors: string[] = []; + + if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); + if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); + if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); + if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); + if (!manifest.type || typeof manifest.type !== 'string') errors.push('Missing or invalid "type"'); + + // Validate ID format (alphanumeric + hyphens) + if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { + errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); + } + + return errors; +} + +export function checkThemeManifest(manifest: Record): { result: ThemeManifest | null; errors: string[] } { + const errors = validateBaseManifest(manifest); + + if (manifest.type !== 'theme') { + errors.push(`Expected type "theme", got "${manifest.type}"`); + } + + if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { + errors.push('Missing or empty "variants" array (must be ["light"], ["dark"], or ["light","dark"])'); + } else { + const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); + if (!valid) errors.push('Variants must be "light" or "dark"'); + } + + // ── Theme API v2 fields (all optional) ── + if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) { + errors.push('"apiVersion" must be 1 or 2 if present'); + } + if (manifest.extends !== undefined && typeof manifest.extends !== 'string') { + errors.push('"extends" must be a string (the parent theme id)'); + } + if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) { + errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps'); + } + if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) { + errors.push('"density" must be "compact", "normal", or "touch"'); + } + if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') { + errors.push('"derive" must be a boolean'); + } + if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) { + errors.push('"radii" must be an object'); + } + if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) { + errors.push('"typography" must be an object'); + } + + if (errors.length > 0) return { result: null, errors }; + + return { + result: manifest as unknown as ThemeManifest, + errors: [], + }; +} + +export interface ThemePackageResult { + valid: boolean; + errors: string[]; + warnings: string[]; + manifest: ThemeManifest | null; + /** Compiled (Theme API v2) and sanitized CSS. */ + css: string; + /** Sanitized skin.css for v2 themes, else null. */ + skin: string | null; + pkg: OpenedPackage | null; +} + +/** + * Open, validate and compile a theme package. v1 themes need theme.css; v2 + * themes may be tokens only and may ship a skin.css. `resolveExtends` returns + * the CSS of an installed theme for `extends`. + */ +export async function readThemePackage( + data: ArrayBuffer | Uint8Array, + opts: { resolveExtends?: (id: string) => string | null } = {}, +): Promise { + const fail = (errors: string[], warnings: string[] = [], manifest: ThemeManifest | null = null, pkg: OpenedPackage | null = null): ThemePackageResult => + ({ valid: false, errors, warnings, manifest, css: '', skin: null, pkg }); + const errors: string[] = []; + const warnings: string[] = []; + + let pkg: OpenedPackage; + let manifestData: Record; + try { + pkg = await openPackage(data, 'theme'); + manifestData = await readManifest(pkg); + } catch (err) { + return fail([err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file']); + } + + const { result: manifest, errors: manifestErrors } = checkThemeManifest(manifestData); + if (!manifest) return fail(manifestErrors, warnings, null, pkg); + + // theme.css is required for v1 themes, optional for v2 ones, whose CSS the + // compiler can produce from the manifest alone. + const isAdvanced = isAdvancedManifest(manifest); + let cssText: string | null; + let skinText: string | null; + try { + cssText = await readText(pkg, 'theme.css'); + skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4); + } catch (err) { + return fail([err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'], warnings, manifest, pkg); + } + + let userCSS = ''; + if (cssText !== null) { + // Sanitize instead of rejecting, and always: the sanitizer is also what + // drops rules outside :root and .dark. + const sanitized = sanitizeThemeCSS(cssText); + userCSS = sanitized.css; + warnings.push(...sanitized.warnings); + } else if (!isAdvanced) { + return fail(['Missing theme.css'], warnings, manifest, pkg); + } + + let css = userCSS; + if (isAdvanced) { + const compiled = compileAdvancedTheme(manifest, { userCSS, resolveExtends: opts.resolveExtends }); + if (compiled.errors.length > 0) return fail([...errors, ...compiled.errors], warnings, manifest, pkg); + warnings.push(...compiled.warnings); + // Defence in depth: sanitize the compiled output too. + const sanitized = sanitizeThemeCSS(compiled.css); + css = sanitized.css; + warnings.push(...sanitized.warnings); + } + + // Skins target real component selectors and skip the :root/.dark selector + // check; they still go through the dangerous-pattern sanitizer. + let skin: string | null = null; + if (skinText !== null) { + if (!isAdvanced) { + warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin'); + } else if (skinText.length > MAX_THEME_SKIN_BYTES) { + warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`); + } else { + const sanitized = sanitizeSkinCSS(skinText); + skin = sanitized.css; + warnings.push(...sanitized.warnings); + } + } + + return { valid: true, errors: [], warnings, manifest, css, skin, pkg }; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index f46a40f0e..39695db07 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -209,6 +209,8 @@ export interface InstalledTheme { builtIn: boolean; managed?: boolean; forceEnabled?: boolean; + /** Server content hash of a managed theme's CSS + skin; see ServerTheme. */ + contentHash?: string; // ─── Advanced (Theme API v2) ─ carried over from the manifest ─ apiVersion?: 1 | 2; diff --git a/lib/plugin-validator.ts b/lib/plugin-validator.ts index 83bb7d06c..4082a04d2 100644 --- a/lib/plugin-validator.ts +++ b/lib/plugin-validator.ts @@ -1,20 +1,11 @@ // Plugin/Theme ZIP upload validation, extraction, and manifest parsing -import { - type ThemeManifest, - type PluginManifest, - MAX_THEME_SKIN_BYTES, -} from './plugin-types'; -import { sanitizeThemeCSS, sanitizeSkinCSS } from './theme-loader'; -import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; +import type { ThemeManifest, PluginManifest } from './plugin-types'; import { ExtensionPackageError, - openPackage, readEntry, - readManifest, readPluginPackage, - readText, - type OpenedPackage, + readThemePackage, } from './extension-package'; export interface ValidationResult { @@ -40,179 +31,25 @@ export interface PluginExtractionResult extends ValidationResult { preview: string | null; } -// ─── Manifest Validation ───────────────────────────────────── - -function validateBaseManifest(manifest: Record): string[] { - const errors: string[] = []; - - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - if (!manifest.type || typeof manifest.type !== 'string') errors.push('Missing or invalid "type"'); - - // Validate ID format (alphanumeric + hyphens) - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - return errors; -} - -function validateThemeManifest(manifest: Record): { result: ThemeManifest | null; errors: string[] } { - const errors = validateBaseManifest(manifest); - - if (manifest.type !== 'theme') { - errors.push(`Expected type "theme", got "${manifest.type}"`); - } - - if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { - errors.push('Missing or empty "variants" array (must be ["light"], ["dark"], or ["light","dark"])'); - } else { - const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); - if (!valid) errors.push('Variants must be "light" or "dark"'); - } - - // ── Theme API v2 fields (all optional) ── - if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) { - errors.push('"apiVersion" must be 1 or 2 if present'); - } - if (manifest.extends !== undefined && typeof manifest.extends !== 'string') { - errors.push('"extends" must be a string (the parent theme id)'); - } - if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) { - errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps'); - } - if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) { - errors.push('"density" must be "compact", "normal", or "touch"'); - } - if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') { - errors.push('"derive" must be a boolean'); - } - if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) { - errors.push('"radii" must be an object'); - } - if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) { - errors.push('"typography" must be an object'); - } - - if (errors.length > 0) return { result: null, errors }; - - return { - result: manifest as unknown as ThemeManifest, - errors: [], - }; -} - // ─── ZIP Extraction ────────────────────────────────────────── /** - * Extract and validate a theme ZIP file. + * Extract and validate a theme ZIP file. Same checks as the admin and + * marketplace installs (see extension-package.ts). */ export async function extractTheme(file: File): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - let pkg: OpenedPackage; - let manifestData: Record; - try { - pkg = await openPackage(await file.arrayBuffer(), 'theme'); - manifestData = await readManifest(pkg); - } catch (err) { - const message = err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file'; - return { valid: false, errors: [message], warnings: [], manifest: null, css: '', skin: null, preview: null }; - } - - const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData); - errors.push(...manifestErrors); - if (!manifest) { - return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null }; - } - - // Read theme.css - required for v1 themes, optional when the manifest - // declares Theme API v2 fields (tokens/extends/derive/density/radii/typography), - // since the compiler can produce CSS purely from the manifest. - const isAdvanced = isAdvancedManifest(manifest); - let cssText: string | null; - let skinText: string | null; - try { - cssText = await readText(pkg, 'theme.css'); - skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4); - } catch (err) { - const message = err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'; - return { valid: false, errors: [message], warnings, manifest, css: '', skin: null, preview: null }; - } - - let userCSS = ''; - if (cssText !== null) { - userCSS = cssText; - // Sanitize instead of rejecting, and always: the sanitizer is also what - // drops rules outside :root and .dark. - const sanitized = sanitizeThemeCSS(userCSS); - userCSS = sanitized.css; - warnings.push(...sanitized.warnings); - } else if (!isAdvanced) { - errors.push('Missing theme.css'); - return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null }; - } - - // Compile advanced tokens into CSS (for v2 manifests). The compiled output - // is concatenated with any user-supplied theme.css for fine-grained overrides. - let rawCSS = userCSS; - if (isAdvanced) { - const compiled = compileAdvancedTheme(manifest, { userCSS }); - if (compiled.errors.length > 0) { - errors.push(...compiled.errors); - return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null }; - } - warnings.push(...compiled.warnings); - rawCSS = compiled.css; - - // Run sanitizer over the final compiled output as a defence-in-depth check. - const sanitized = sanitizeThemeCSS(rawCSS); - rawCSS = sanitized.css; - warnings.push(...sanitized.warnings); - } - - // Read preview image if present + const result = await readThemePackage(await file.arrayBuffer()); + const { pkg, ...rest } = result; let preview: string | null = null; - if (manifest.preview) { + if (pkg && result.manifest?.preview) { try { - const bytes = await readEntry(pkg, manifest.preview, 512 * 1024); - if (bytes) preview = await blobToDataUri(new Blob([bytes as BlobPart], { type: previewType(manifest.preview) })); + const bytes = await readEntry(pkg, result.manifest.preview, 512 * 1024); + if (bytes) preview = await blobToDataUri(new Blob([bytes as BlobPart], { type: previewType(result.manifest.preview) })); } catch { - warnings.push('Could not read preview image'); + rest.warnings.push('Could not read preview image'); } } - - // Read skin.css if present (Theme API v2 only). Skins target real - // component selectors and bypass the strict :root/.dark selector check - - // they still go through the dangerous-pattern sanitizer. - let skin: string | null = null; - if (skinText !== null) { - if (!isAdvanced) { - warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin'); - } else { - const rawSkin = skinText; - if (rawSkin.length > MAX_THEME_SKIN_BYTES) { - warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`); - } else { - const sanitized = sanitizeSkinCSS(rawSkin); - skin = sanitized.css; - warnings.push(...sanitized.warnings); - } - } - } - - return { - valid: errors.length === 0, - errors, - warnings, - manifest, - css: rawCSS, - skin, - preview, - }; + return { ...rest, preview }; } /** diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 3427dc5ea..d06df2822 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -6,6 +6,7 @@ import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS, + sanitizeSkinCSS, injectThemeSkinCSS, removeThemeSkinCSS, } from '@/lib/theme-loader'; @@ -353,10 +354,12 @@ export const useThemeStore = create()( if (!local) { // Download and install new server theme - const css = await downloadThemeCSS(st.id); + const css = await downloadThemeAsset(st, 'css'); if (!css) continue; + const skin = st.hasSkin ? await downloadThemeAsset(st, 'skin') : null; const sanitized = sanitizeThemeCSS(css); + const sanitizedSkin = skin ? sanitizeSkinCSS(skin).css : undefined; const theme: InstalledTheme = { id: st.id, name: st.name, @@ -369,9 +372,12 @@ export const useThemeStore = create()( builtIn: false, managed: true, forceEnabled: st.forceEnabled, + contentHash: st.contentHash, + ...(sanitizedSkin ? { skin: sanitizedSkin } : {}), }; await pluginStorage.saveThemeCSS(st.id, sanitized.css); + if (sanitizedSkin) await pluginStorage.saveThemeSkin(st.id, sanitizedSkin); set(state => { if (state.installedThemes.some(t => t.id === st.id)) { return {}; @@ -383,15 +389,25 @@ export const useThemeStore = create()( } else if (!local.builtIn) { let css = local.css; + let skin = local.skin; + // The hash catches a re-upload of the same version. + const changed = local.version !== st.version + || (!!st.contentHash && local.contentHash !== st.contentHash); - if (local.version !== st.version || !css) { - const downloadedCss = await downloadThemeCSS(st.id); + if (changed || !css) { + const downloadedCss = await downloadThemeAsset(st, 'css'); if (!downloadedCss) continue; const sanitized = sanitizeThemeCSS(downloadedCss); css = sanitized.css; await pluginStorage.saveThemeCSS(st.id, sanitized.css); } + if (changed) { + const downloadedSkin = st.hasSkin ? await downloadThemeAsset(st, 'skin') : null; + skin = downloadedSkin ? sanitizeSkinCSS(downloadedSkin).css : undefined; + if (skin) await pluginStorage.saveThemeSkin(st.id, skin); + else await pluginStorage.deleteThemeSkin(st.id); + } const updatedTheme = { ...local, @@ -400,9 +416,11 @@ export const useThemeStore = create()( author: st.author, description: st.description || '', css, + skin, variants: st.variants as ThemeVariant[], managed: true, forceEnabled: st.forceEnabled, + contentHash: st.contentHash, }; set(state => ({ @@ -587,6 +605,8 @@ interface ServerThemeInfo { description: string; variants: string[]; forceEnabled: boolean; + hasSkin?: boolean; + contentHash?: string; } function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] { @@ -612,13 +632,16 @@ function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] { return [...byId.values()]; } -async function downloadThemeCSS(themeId: string): Promise { +async function downloadThemeAsset(theme: ServerThemeInfo, kind: 'css' | 'skin'): Promise { + // The content hash in the URL keeps a browser cache from serving the + // previous upload. + const version = theme.contentHash ? `?v=${encodeURIComponent(theme.contentHash)}` : ''; try { - const res = await apiFetch(`/api/admin/themes/${encodeURIComponent(themeId)}/css`); + const res = await apiFetch(`/api/admin/themes/${encodeURIComponent(theme.id)}/${kind}${version}`); if (!res.ok) return null; return await res.text(); } catch { - console.warn(`[theme-store] Failed to download CSS for theme "${themeId}"`); + console.warn(`[theme-store] Failed to download ${kind} for theme "${theme.id}"`); return null; } } \ No newline at end of file From e720be7e17ce8bafde58cfb761d58bfb89870b22 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:03:26 +0200 Subject: [PATCH 007/147] feat: let users install their own plugins, with admin review of the actual code The admin page offered "User Plugin Uploads" and "Require Admin Approval", but nothing in Settings used them: there was no upload, no uninstall and no way to ask for approval, and the admin had no list of requests. The approval code that existed pinned a hash of the code only and showed the admin a manifest the browser sent, never the code. Now: - Settings > Plugins has an install button (when uploads are allowed), a remove button for the user's own plugins, and lists in plain language what each plugin may do, high-risk items first - an install sends the ZIP to the server, which reads the manifest and code itself and pins the request to a hash of both - the admin Plugins tab lists requests with the code and approves or denies them; a user may have 10 open requests - a user plugin is checked again on every start, so a revoked approval or turning user plugins off takes effect - the consent dialog and the plugin settings are translated, use the theme colours, and show the sites a plugin may send data to --- .../admin/_tabs/plugin-approvals-panel.tsx | 192 +++++++++++ app/(main)/admin/_tabs/plugins.tsx | 3 + .../admin/plugin-approvals/source/route.ts | 34 ++ .../__tests__/route.test.ts | 98 ++++++ app/api/plugin-approval-status/route.ts | 75 +++-- components/plugins/plugin-consent-dialog.tsx | 136 +++----- components/plugins/plugin-permission-list.tsx | 57 ++++ components/settings/plugins-settings.tsx | 309 ++++++++++++------ lib/__tests__/plugin-store.test.ts | 91 ++++++ lib/admin/plugin-approvals.ts | 43 ++- lib/admin/types.ts | 4 + lib/extension-package.ts | 23 ++ lib/plugin-sandbox/consent.ts | 56 +--- lib/plugin-sandbox/permission-text.ts | 36 ++ lib/plugin-types.ts | 9 + locales/ar/common.json | 270 +++++++++++++++ locales/ca/common.json | 270 +++++++++++++++ locales/cs/common.json | 270 +++++++++++++++ locales/da/common.json | 270 +++++++++++++++ locales/de/common.json | 270 +++++++++++++++ locales/en/common.json | 270 +++++++++++++++ locales/es/common.json | 270 +++++++++++++++ locales/fa/common.json | 270 +++++++++++++++ locales/fr/common.json | 270 +++++++++++++++ locales/he/common.json | 270 +++++++++++++++ locales/hu/common.json | 270 +++++++++++++++ locales/it/common.json | 270 +++++++++++++++ locales/ja/common.json | 270 +++++++++++++++ locales/ko/common.json | 270 +++++++++++++++ locales/lv/common.json | 270 +++++++++++++++ locales/mn/common.json | 270 +++++++++++++++ locales/nb/common.json | 270 +++++++++++++++ locales/nl/common.json | 270 +++++++++++++++ locales/pl/common.json | 270 +++++++++++++++ locales/pt/common.json | 270 +++++++++++++++ locales/ro/common.json | 270 +++++++++++++++ locales/ru/common.json | 270 +++++++++++++++ locales/sk/common.json | 270 +++++++++++++++ locales/tr/common.json | 270 +++++++++++++++ locales/uk/common.json | 270 +++++++++++++++ locales/zh-TW/common.json | 270 +++++++++++++++ locales/zh/common.json | 270 +++++++++++++++ stores/plugin-store.ts | 162 ++++++--- stores/policy-store.ts | 5 - 44 files changed, 8301 insertions(+), 322 deletions(-) create mode 100644 app/(main)/admin/_tabs/plugin-approvals-panel.tsx create mode 100644 app/api/admin/plugin-approvals/source/route.ts create mode 100644 app/api/plugin-approval-status/__tests__/route.test.ts create mode 100644 components/plugins/plugin-permission-list.tsx create mode 100644 lib/plugin-sandbox/permission-text.ts diff --git a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx new file mode 100644 index 000000000..51fe96758 --- /dev/null +++ b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; + +interface ApprovalEntry { + pluginId: string; + bundleHash: string; + status: 'pending' | 'approved' | 'denied'; + manifest: { + name?: string; + version?: string; + author?: string; + description?: string; + permissions?: string[]; + httpOrigins?: string[]; + apiPostPaths?: string[]; + }; + requestedBy: string; + requestedAt: string; + decidedAt?: string; +} + +interface SourceView { + entry: ApprovalEntry; + code?: string; + warnings?: string[]; + error?: string; +} + +const STATUS_STYLE: Record = { + pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400', + approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400', + denied: 'bg-muted text-muted-foreground', +}; + +/** + * Plugins users installed themselves and asked to have approved. The admin + * decides on the package the user uploaded: its manifest and code are read + * on the server, and the approval is pinned to that exact package. + */ +export function PluginApprovalsPanel() { + const [entries, setEntries] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [source, setSource] = useState(null); + + const load = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/plugin-approvals'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { entries: ApprovalEntry[] }; + setEntries([...data.entries].sort((a, b) => + Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt))); + setError(null); + } catch (e) { + setError(`Could not load approval requests (${(e as Error).message})`); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') { + const key = `${entry.pluginId}:${entry.bundleHash}`; + setBusy(key); + try { + const res = decision === 'revoke' + ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' }) + : await apiFetch('/api/admin/plugin-approvals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); + } catch (e) { + setError(`Could not save the decision (${(e as Error).message})`); + } finally { + setBusy(null); + } + } + + async function viewSource(entry: ApprovalEntry) { + setSource({ entry }); + try { + const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + setSource({ entry, code: data.code, warnings: data.warnings }); + } catch (e) { + setSource({ entry, error: (e as Error).message }); + } + } + + const pending = entries?.filter((e) => e.status === 'pending').length ?? 0; + + return ( +
+
+
+ +

Approval Requests

+ {pending > 0 && ( + {pending} waiting + )} +
+

Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.

+
+ + {error &&
{error}
} + + {entries === null ? ( +
+ ) : entries.length === 0 ? ( +
No requests yet
+ ) : ( +
+ {entries.map((entry) => { + const key = `${entry.pluginId}:${entry.bundleHash}`; + const perms = entry.manifest.permissions ?? []; + return ( +
+
+ {entry.manifest.name ?? entry.pluginId} + v{entry.manifest.version} + {entry.status} +
+ {entry.manifest.description &&

{entry.manifest.description}

} +
+ {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()} +
+ {perms.length > 0 && ( +
+ {perms.map((p) => ( + {p} + ))} +
+ )} + {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && ( +
+ Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')} +
+ )} +
+ + {entry.status !== 'approved' && ( + + )} + {entry.status !== 'denied' && ( + + )} + {entry.status !== 'pending' && ( + + )} +
+
+ ); + })} +
+ )} + + {source && ( +
{ if (e.target === e.currentTarget) setSource(null); }}> +
+
+ {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version} + +
+ {source.warnings && source.warnings.length > 0 && ( +
+ {source.warnings.join('; ')} +
+ )} +
+              {source.error ?? source.code ?? 'Loading…'}
+            
+
+
+ )} +
+ ); +} diff --git a/app/(main)/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx index 55b25b642..908820b7e 100644 --- a/app/(main)/admin/_tabs/plugins.tsx +++ b/app/(main)/admin/_tabs/plugins.tsx @@ -6,6 +6,7 @@ import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; import { PluginConfigPanel } from './plugin-config-panel'; +import { PluginApprovalsPanel } from './plugin-approvals-panel'; interface PluginEntry { id: string; @@ -374,6 +375,8 @@ export function PluginsTab() {
+ {pluginsUploadEnabled && } +
diff --git a/app/api/admin/plugin-approvals/source/route.ts b/app/api/admin/plugin-approvals/source/route.ts new file mode 100644 index 000000000..c966124e1 --- /dev/null +++ b/app/api/admin/plugin-approvals/source/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { logger } from '@/lib/logger'; +import { readApprovalBundle } from '@/lib/admin/plugin-approvals'; +import { readPluginPackage } from '@/lib/extension-package'; + +/** + * GET /api/admin/plugin-approvals/source?pluginId=…&bundleHash=… + * + * The manifest and code of a plugin a user asked to have approved, read + * from the ZIP they uploaded, so the admin decides on what will actually run. + */ +export async function GET(request: NextRequest) { + try { + const result = await requireAdminAuth(request); + if ('error' in result) return result.error; + + const pluginId = request.nextUrl.searchParams.get('pluginId') ?? ''; + const bundleHash = request.nextUrl.searchParams.get('bundleHash') ?? ''; + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(pluginId) || !/^[a-f0-9]{64}$/.test(bundleHash)) { + return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + } + const zip = await readApprovalBundle(pluginId, bundleHash); + if (!zip) return NextResponse.json({ error: 'No package stored for this request' }, { status: 404 }); + const pkg = await readPluginPackage(zip); + return NextResponse.json( + { manifest: pkg.manifest, code: pkg.code, warnings: pkg.warnings }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (err) { + logger.error('plugin-approvals source GET', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/plugin-approval-status/__tests__/route.test.ts b/app/api/plugin-approval-status/__tests__/route.test.ts new file mode 100644 index 000000000..7d0ba7970 --- /dev/null +++ b/app/api/plugin-approval-status/__tests__/route.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) })); +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => (slot === 0 ? { username: currentUser } : null), +})); +vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null })); + +let currentUser = 'alice@example.org'; +const entries: Array> = []; +const bundles = new Map(); +vi.mock('@/lib/admin/plugin-approvals', async (orig) => { + const real = await orig(); + return { + ...real, + getApprovalStatus: vi.fn(async () => ({ status: 'not-requested' })), + requestApproval: vi.fn(async (pluginId: string, bundleHash: string, manifest: unknown, requestedBy: string) => { + const pending = entries.filter((e) => e.status === 'pending' && e.requestedBy === requestedBy).length; + if (pending >= real.MAX_PENDING_PER_USER) throw new real.ApprovalLimitError('too many'); + const entry = { pluginId, bundleHash, manifest, requestedBy, status: 'pending', requestedAt: 'now' }; + entries.push(entry); + return entry; + }), + saveApprovalBundle: vi.fn(async (id: string, hash: string, zip: Uint8Array) => { bundles.set(`${id}:${hash}`, zip); }), + }; +}); + +import { POST } from '../route'; +import { packageHash, checkPluginManifest } from '@/lib/extension-package'; + +const MANIFEST = { + id: 'my-plugin', name: 'Mine', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function zipOf(manifest: Record, code = 'module.exports = {}') { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest)); + zip.file('index.js', code); + return zip.generateAsync({ type: 'uint8array' }); +} + +async function submit(bytes: Uint8Array) { + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'p.zip')); + return POST(new NextRequest('http://localhost/api/plugin-approval-status', { method: 'POST', body: form })); +} + +beforeEach(() => { + entries.length = 0; + bundles.clear(); + currentUser = 'alice@example.org'; +}); + +describe('POST /api/plugin-approval-status', () => { + it('pins the request to the hash of the code and manifest it read itself', async () => { + const res = await submit(await zipOf(MANIFEST)); + const body = await res.json(); + + const expected = await packageHash(checkPluginManifest(MANIFEST).manifest!, 'module.exports = {}'); + expect(res.status).toBe(200); + expect(body.bundleHash).toBe(expected); + expect(entries[0].manifest).toMatchObject({ name: 'Mine', permissions: ['email:read'] }); + expect(bundles.has(`my-plugin:${expected}`)).toBe(true); + }); + + it('gives the same code with wider permissions a different hash', async () => { + const narrow = await (await submit(await zipOf(MANIFEST))).json(); + const wide = await (await submit(await zipOf({ ...MANIFEST, permissions: ['email:read', 'http:fetch'], httpOrigins: ['https://evil.example'] }))).json(); + + expect(wide.bundleHash).not.toBe(narrow.bundleHash); + }); + + it('refuses the old form where the client named the hash and manifest', async () => { + const res = await POST(new NextRequest('http://localhost/api/plugin-approval-status', { + method: 'POST', + body: JSON.stringify({ pluginId: 'my-plugin', bundleHash: 'a'.repeat(64), manifest: { permissions: [] } }), + headers: { 'content-type': 'application/json' }, + })); + + expect(res.status).toBe(400); + expect(entries).toHaveLength(0); + }); + + it('limits how many requests one user can have open', async () => { + for (let i = 0; i < 10; i++) { + expect((await submit(await zipOf({ ...MANIFEST, id: `p-${i}x` }))).status).toBe(200); + } + const res = await submit(await zipOf({ ...MANIFEST, id: 'one-more' })); + + expect(res.status).toBe(429); + }); +}); diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts index 08f99867e..d33f659c6 100644 --- a/app/api/plugin-approval-status/route.ts +++ b/app/api/plugin-approval-status/route.ts @@ -6,7 +6,14 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { logger } from '@/lib/logger'; -import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals'; +import { + ApprovalLimitError, + getApprovalStatus, + requestApproval, + saveApprovalBundle, + type ApprovalEntry, +} from '@/lib/admin/plugin-approvals'; +import { ExtensionPackageError, packageHash, readPluginPackage } from '@/lib/extension-package'; /** * GET /api/plugin-approval-status?pluginId=X&bundleHash=Y @@ -15,8 +22,9 @@ import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/ad * they want to enable. The client uses this BEFORE running `enablePlugin` * when the `requirePluginApproval` policy is set. * - * POST same path with body `{ pluginId, bundleHash, manifest }` creates a - * pending approval entry (or returns the existing one). + * POST same path with the plugin ZIP as multipart `file` creates a pending + * approval entry (or returns the existing one) for the package hash the + * server computes, and keeps the ZIP for the admin to review. */ async function resolveUsername(): Promise { @@ -67,26 +75,55 @@ export async function POST(request: NextRequest) { const username = await resolveUsername(); if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }); - let body: unknown; - try { body = await request.json(); } catch { body = null; } - const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown }; - if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) { - return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + // The request carries the package itself. The admin reviews the code + // and manifest the server read from it, and the approval is pinned to + // the hash the server computed, not to anything the client claims. + let file: File | null = null; + try { + const form = await request.formData(); + const value = form.get('file'); + file = value instanceof File ? value : null; + } catch { /* not multipart */ } + if (!file) { + return NextResponse.json({ error: 'Send the plugin ZIP as "file"' }, { status: 400 }); } + const bytes = new Uint8Array(await file.arrayBuffer()); - const m = (b.manifest ?? {}) as Record; - const manifest: ApprovalEntry['manifest'] = { - name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined, - version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined, - author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined, - description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined, - permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined, - httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined, - apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined, + let pkg; + try { + pkg = await readPluginPackage(bytes, { appVersion: process.env.NEXT_PUBLIC_APP_VERSION }); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + const { manifest, code } = pkg; + const hash = await packageHash(manifest, code); + + const snapshot: ApprovalEntry['manifest'] = { + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description, + permissions: manifest.permissions, + httpOrigins: manifest.httpOrigins, + apiPostPaths: manifest.apiPostPaths, }; - const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username); - return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt }); + let entry: ApprovalEntry; + try { + entry = await requestApproval(manifest.id, hash, snapshot, username); + } catch (err) { + if (err instanceof ApprovalLimitError) return NextResponse.json({ error: err.message }, { status: 429 }); + throw err; + } + if (entry.status === 'pending') await saveApprovalBundle(manifest.id, hash, bytes); + return NextResponse.json({ + pluginId: manifest.id, + bundleHash: hash, + status: entry.status, + requestedAt: entry.requestedAt, + decidedAt: entry.decidedAt, + }); } catch (err) { logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/components/plugins/plugin-consent-dialog.tsx b/components/plugins/plugin-consent-dialog.tsx index 4194245fc..8699f05f4 100644 --- a/components/plugins/plugin-consent-dialog.tsx +++ b/components/plugins/plugin-consent-dialog.tsx @@ -4,109 +4,61 @@ // the plugin's manifest declares. Accepting persists the grant on the // plugin record so future enables skip the prompt. -import React, { useEffect, useSyncExternalStore } from 'react'; -import { head, resolveHead, subscribe, describePermission } from '@/lib/plugin-sandbox/consent'; +import React, { useId, useSyncExternalStore } from 'react'; +import { useTranslations } from 'next-intl'; +import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/consent'; +import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; +import { useFocusTrap } from '@/hooks/use-focus-trap'; +import { Button } from '@/components/ui/button'; +import { PluginPermissionList } from './plugin-permission-list'; export function PluginConsentDialog(): React.JSX.Element | null { const current = useSyncExternalStore(subscribe, head, () => null); - - useEffect(() => { - if (!current) return; - function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') { - e.preventDefault(); - resolveHead(false); - } - } - document.addEventListener('keydown', onKey, true); - return () => document.removeEventListener('keydown', onKey, true); - }, [current]); + const t = useTranslations('plugins.consent'); + const id = useId(); + const dialogRef = useFocusTrap({ + isActive: !!current, + onEscape: () => resolveHead(false), + restoreFocus: true, + }); if (!current) return null; + const risky = current.permissions.some((p) => HIGH_RISK_PERMISSIONS.has(p)); return (
{ if (e.target === e.currentTarget) resolveHead(false); }} > -
- -

- This plugin is requesting the permissions below. You can revoke them by uninstalling the plugin. -

- -
    - {current.permissions.map((perm) => { - const desc = describePermission(perm); - return ( -
  • -
    {desc.title}
    -
    {desc.body}
    - {perm} -
  • - ); - })} -
- -
- - +
+
+

+ {t('title', { name: current.pluginName })} +

+

{t('intro')}

+ {risky && ( +

{t('high_risk_note')}

+ )}
-
- Plugin: {current.pluginId} +
+ +
+
+ {t('revoke_hint')} +
+ + +
diff --git a/components/plugins/plugin-permission-list.tsx b/components/plugins/plugin-permission-list.tsx new file mode 100644 index 000000000..71b91ffb9 --- /dev/null +++ b/components/plugins/plugin-permission-list.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { AlertTriangle } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { HIGH_RISK_PERMISSIONS, permissionKey, visiblePermissions } from '@/lib/plugin-sandbox/permission-text'; + +interface PluginPermissionListProps { + permissions: readonly string[]; + /** Sites the plugin may send requests to (manifest httpOrigins). */ + httpOrigins?: readonly string[]; + /** Server endpoints it may call with the user's login (apiPostPaths). */ + apiPostPaths?: readonly string[]; + compact?: boolean; +} + +/** Plain-language list of what a plugin may do, high-risk items first. */ +export function PluginPermissionList({ permissions, httpOrigins, apiPostPaths, compact }: PluginPermissionListProps) { + const t = useTranslations('plugins.permissions'); + const perms = visiblePermissions(permissions); + if (perms.length === 0) { + return

{t('none')}

; + } + return ( +
    + {perms.map((perm) => { + const key = permissionKey(perm); + const risky = HIGH_RISK_PERMISSIONS.has(perm); + const targets = perm === 'http:fetch' ? httpOrigins : perm === 'http:post' ? apiPostPaths : undefined; + return ( +
  • +
    + {risky && } +
    +
    {t(`${key}.title`)}
    + {!compact &&
    {t(`${key}.body`)}
    } + {targets && targets.length > 0 && ( +
      + {targets.map((target) => ( +
    • {target}
    • + ))} +
    + )} +
    +
    +
  • + ); + })} +
+ ); +} diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index b529bf882..625a98e7c 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -1,12 +1,17 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; import { usePluginStore } from '@/stores/plugin-store'; import { usePolicyStore } from '@/stores/policy-store'; +import { useConfig } from '@/hooks/use-config'; import { SettingsSection, ToggleSwitch } from './settings-section'; import { cn } from '@/lib/utils'; -import { AlertTriangle, Puzzle, Lock, Server } from '@/components/icons'; +import { AlertTriangle, Puzzle, Lock, Server, Upload, Trash2, Loader2 } from '@/components/icons'; import { toast } from '@/stores/toast-store'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { PluginPermissionList } from '@/components/plugins/plugin-permission-list'; import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; const STATUS_COLORS: Record = { @@ -17,10 +22,19 @@ const STATUS_COLORS: Record = { error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', }; +/** Errors the store records as codes, so they can be shown translated. */ +const ERROR_CODES = new Set(['user-plugins-off', 'approval-unavailable', 'approval-denied', 'approval-pending']); + export function PluginsSettings() { - const { plugins, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); - const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore(); + const t = useTranslations('plugins.settings'); + const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); + const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore(); + const { demoMode } = useConfig(); const [expandedPlugin, setExpandedPlugin] = useState(null); + const [installing, setInstalling] = useState(false); + const [installError, setInstallError] = useState(null); + const [removing, setRemoving] = useState(null); + const fileInput = useRef(null); useEffect(() => { if (!loaded) { @@ -45,67 +59,137 @@ export function PluginsSettings() { return null; } + const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode; + const requireApproval = isFeatureEnabled('requirePluginApproval'); + const handleToggle = async (plugin: InstalledPlugin) => { if (!initialized) return; - const isForceEnabled = plugin.forceEnabled || isPluginForceEnabled(plugin.id); - if (isForceEnabled) { - toast.info(`Plugin "${plugin.name}" is forced by admin and cannot be disabled`); - return; - } - - const requireApproval = isFeatureEnabled('requirePluginApproval'); - const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id); - if (!plugin.enabled && requireApproval && !isApproved) { - toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`); + if (plugin.forceEnabled || isPluginForceEnabled(plugin.id)) { + toast.info(t('toast_forced', { name: plugin.name })); return; } if (plugin.enabled) { disablePlugin(plugin.id); - toast.info(`Plugin "${plugin.name}" disabled`); - } else { - await enablePlugin(plugin.id); - toast.success(`Plugin "${plugin.name}" enabled`); + toast.info(t('toast_disabled', { name: plugin.name })); + return; + } + await enablePlugin(plugin.id); + const after = usePluginStore.getState().plugins.find((p) => p.id === plugin.id); + if (after?.enabled) { + toast.success(t('toast_enabled', { name: plugin.name })); + } else if (after?.error === 'approval-pending') { + toast.info(t('toast_approval_requested', { name: plugin.name })); + } + }; + + const handleInstall = async (file: File | undefined) => { + if (!file) return; + setInstalling(true); + setInstallError(null); + try { + const result = await installPlugin(file); + if (!result.success) { + setInstallError( + result.reason === 'uploads-disabled' ? t('install_uploads_off') + : result.reason === 'managed-id' ? t('install_managed_id') + : t('install_invalid', { details: result.error ?? '' }), + ); + return; + } + toast.success(result.approvalStatus === 'pending' + ? t('toast_installed_pending') + : t('toast_installed')); + } finally { + setInstalling(false); + if (fileInput.current) fileInput.current.value = ''; } }; return ( - - {/* Plugin List */} + + {uploadsAllowed && ( +
+
+ + + {requireApproval ? t('install_hint_approval') : t('install_hint')} + + handleInstall(e.target.files?.[0])} + /> +
+ {installError && ( +
+ + {installError} +
+ )} +
+ )} + {plugins.length === 0 ? (
- -

No plugins available

-

Your administrator has not deployed any plugins

+ +

{t('empty_title')}

+

+ {demoMode ? t('empty_demo') : uploadsAllowed ? t('empty_uploads') : t('empty_admin')} +

) : (
- {plugins.map(plugin => { - const requireApproval = isFeatureEnabled('requirePluginApproval'); - const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id); - const needsApproval = requireApproval && !isApproved; - return ( + {plugins.map(plugin => ( setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)} onToggle={() => handleToggle(plugin)} + onRemove={() => setRemoving(plugin)} onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)} /> - ); - })} + ))}
)} {!initialized && plugins.length > 0 && ( -

Syncing plugin policy and managed state...

+

{t('syncing')}

)} + + setRemoving(null)} + onConfirm={() => { + if (removing) { + uninstallPlugin(removing.id); + toast.info(t('toast_removed', { name: removing.name })); + } + setRemoving(null); + }} + title={t('remove_title', { name: removing?.name ?? '' })} + message={t('remove_message')} + confirmText={t('remove_confirm')} + variant="destructive" + />
); } @@ -116,15 +200,22 @@ interface PluginCardProps { plugin: InstalledPlugin; isExpanded: boolean; isForceEnabled: boolean; - isManaged: boolean; - needsApproval: boolean; controlsDisabled: boolean; onToggleExpand: () => void; onToggle: () => void; + onRemove: () => void; onUpdateSettings: (settings: Record) => void; } -function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUpdateSettings }: PluginCardProps) { +function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) { + const t = useTranslations('plugins.settings'); + const isManaged = Boolean(plugin.managed); + const awaiting = !isManaged && plugin.approvalStatus === 'pending'; + const denied = !isManaged && plugin.approvalStatus === 'denied'; + const errorText = plugin.error + ? ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error + : null; + return (
- {/* Header */}
-
-
- {plugin.name} - - {plugin.status} + -
- +
+
- {/* Expanded Details */} {isExpanded && ( -
- {isForceEnabled && ( -

This plugin is forced by an administrator and cannot be disabled.

- )} +
+ {isForceEnabled &&

{t('forced_note')}

} - {needsApproval && ( -

This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.

- )} + {plugin.description &&

{plugin.description}

} - {/* Description */} - {plugin.description && ( -

{plugin.description}

- )} - - {/* Error */} - {plugin.error && ( -
- - {plugin.error} + {errorText && ( +
+ + {errorText}
)} - {/* Permissions */} - {plugin.permissions.length > 0 && ( -
- Permissions: -
- {plugin.permissions.map(perm => ( - - {perm} - - ))} -
+
+ + {isManaged ? t('permissions_managed') : t('permissions')} + +
+
- )} +
- {/* Settings (auto-generated from schema) */} {plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && (
- Settings: + {t('settings')} {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( )} + + {!isManaged && ( +
+ +
+ )}
)}
@@ -237,32 +336,34 @@ interface PluginSettingFieldProps { onChange: (value: unknown) => void; } -function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps) { +function PluginSettingField({ fieldKey, schema, value, onChange }: PluginSettingFieldProps) { + const inputId = `plugin-setting-${fieldKey}`; switch (schema.type) { case 'boolean': return ( -
+
{schema.label} {schema.description &&

{schema.description}

}
- onChange(v)} /> + onChange(v)} ariaLabel={schema.label} />
); case 'select': return ( -
+
- {schema.label} + {schema.description &&

{schema.description}

}
@@ -272,31 +373,33 @@ function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps case 'string': return (
- {schema.label} + {schema.description &&

{schema.description}

} onChange(e.target.value)} - className="mt-1 w-full text-xs bg-background border border-border rounded px-2 py-1 text-foreground" + className="mt-1 w-full rounded border border-border bg-background px-2 py-1 text-xs text-foreground" />
); case 'number': return ( -
+
- {schema.label} + {schema.description &&

{schema.description}

}
onChange(Number(e.target.value))} - className="w-20 text-xs bg-background border border-border rounded px-2 py-1 text-foreground" + className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground" />
); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 1e1a791cb..5b3d5a94a 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -243,3 +243,94 @@ describe('installPlugin', () => { expect(usePluginStore.getState().plugins[0].locales).toEqual({ de: { hello: 'Hallo' } }); }); }); + +describe('plugins the user installs', () => { + const MANIFEST = { + id: 'mine', name: 'Mine', version: '1.0.0', author: 'a', description: '', type: 'hook' as const, + permissions: [], entrypoint: 'index.js', + }; + + async function setFeatures(features: Record) { + const { usePolicyStore } = await import('@/stores/policy-store'); + const { DEFAULT_POLICY } = await import('@/lib/admin/types'); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, ...features } } }); + } + + async function mockExtract() { + const { extractPlugin } = await import('@/lib/plugin-validator'); + vi.mocked(extractPlugin).mockResolvedValue({ + valid: true, errors: [], warnings: [], preview: null, code: 'module.exports = {}', manifest: MANIFEST, + }); + } + + beforeEach(() => { + resetStore(); + localStorage.removeItem('server-managed-plugin-ids'); + mocks.apiFetch.mockReset(); + }); + + it('refuses an install when the admin turned uploads off', async () => { + await setFeatures({ pluginsUploadEnabled: false }); + await mockExtract(); + + const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + + expect(result).toMatchObject({ success: false, reason: 'uploads-disabled' }); + expect(usePluginStore.getState().plugins).toHaveLength(0); + }); + + it('refuses a package that reuses the id of an admin plugin', async () => { + await setFeatures({ pluginsUploadEnabled: true, requirePluginApproval: false }); + await mockExtract(); + usePluginStore.setState({ plugins: [mockPlugin({ id: 'mine', managed: true })] }); + + const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + + expect(result).toMatchObject({ success: false, reason: 'managed-id' }); + expect(usePluginStore.getState().plugins[0].managed).toBe(true); + }); + + it('sends the package for approval and keeps it off until approved', async () => { + await setFeatures({ pluginsUploadEnabled: true, requirePluginApproval: true }); + await mockExtract(); + mocks.apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (init?.method === 'POST') return new Response(JSON.stringify({ status: 'pending' })); + return new Response(JSON.stringify({ status: 'pending' })); + }); + + const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + await usePluginStore.getState().enablePlugin('mine'); + + expect(result.approvalStatus).toBe('pending'); + const posted = mocks.apiFetch.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'POST'); + expect((posted?.[1] as RequestInit).body).toBeInstanceOf(FormData); + const plugin = usePluginStore.getState().plugins[0]; + expect(plugin.enabled).toBe(false); + expect(plugin.error).toBe('approval-pending'); + }); + + it('turns an approved package on', async () => { + await setFeatures({ pluginsUploadEnabled: true, requirePluginApproval: true }); + await mockExtract(); + mocks.apiFetch.mockImplementation(async () => new Response(JSON.stringify({ status: 'approved' }))); + + await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + await usePluginStore.getState().enablePlugin('mine'); + + expect(usePluginStore.getState().plugins[0].enabled).toBe(true); + }); + + it('does not load a user plugin whose approval was revoked', async () => { + await setFeatures({ pluginsUploadEnabled: true, requirePluginApproval: true }); + const { loadPlugin } = await import('@/lib/plugin-loader'); + vi.mocked(loadPlugin).mockClear(); + usePluginStore.setState({ plugins: [mockPlugin({ id: 'mine', enabled: true, status: 'enabled', managed: false, approvalHash: 'a'.repeat(64) })] }); + mocks.apiFetch.mockImplementation(async (url: string) => + url.startsWith('/api/plugins') ? new Response(JSON.stringify({ plugins: [], themes: [] })) : new Response(JSON.stringify({ status: 'denied' }))); + + await usePluginStore.getState().initializePlugins(); + + expect(loadPlugin).not.toHaveBeenCalled(); + expect(usePluginStore.getState().plugins[0].error).toBe('approval-denied'); + }); +}); diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts index 12fec4879..2654b9298 100644 --- a/lib/admin/plugin-approvals.ts +++ b/lib/admin/plugin-approvals.ts @@ -10,7 +10,8 @@ // admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we // don't keep asking). -import { readFile, writeFile, rename } from 'node:fs/promises'; +import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises'; +import nodePath from 'node:path'; import { existsSync } from 'node:fs'; import { logger } from '@/lib/logger'; import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; @@ -43,6 +44,31 @@ interface ApprovalsFile { const APPROVALS_FILE = 'plugin-approvals.json'; const MAX_ENTRIES = 500; // hard cap so a misbehaving client can't grow the file unboundedly +/** Open requests one user may have at a time, so one account can't crowd out the rest. */ +export const MAX_PENDING_PER_USER = 10; + +/** The package a user asked to have approved, kept so the admin can read the code. */ +function bundlePath(pluginId: string, bundleHash: string): string { + return nodePath.join(getConfigPath('plugin-approvals'), `${pluginId}-${bundleHash}.zip`); +} + +export async function saveApprovalBundle(pluginId: string, bundleHash: string, zip: Uint8Array): Promise { + assertWritable('plugin-approvals.saveBundle'); + await mkdir(getConfigPath('plugin-approvals'), { recursive: true }); + await writeFile(bundlePath(pluginId, bundleHash), zip); +} + +export async function readApprovalBundle(pluginId: string, bundleHash: string): Promise { + try { + return new Uint8Array(await readFile(bundlePath(pluginId, bundleHash))); + } catch { + return null; + } +} + +async function deleteApprovalBundle(pluginId: string, bundleHash: string): Promise { + try { await unlink(bundlePath(pluginId, bundleHash)); } catch { /* none */ } +} let cached: ApprovalsFile | null = null; let loadPromise: Promise | null = null; @@ -122,12 +148,20 @@ export async function requestApproval( const file = cached!; const existing = findEntry(file, pluginId, bundleHash); if (existing) return existing; + const pendingByUser = file.entries.filter(e => e.status === 'pending' && e.requestedBy === requestedBy).length; + if (pendingByUser >= MAX_PENDING_PER_USER) { + throw new ApprovalLimitError(`You already have ${MAX_PENDING_PER_USER} plugins waiting for approval`); + } if (file.entries.length >= MAX_ENTRIES) { // Drop the oldest pending entry so a new request can land. Approved/denied // entries are preserved. const oldestPendingIdx = file.entries.findIndex(e => e.status === 'pending'); - if (oldestPendingIdx >= 0) file.entries.splice(oldestPendingIdx, 1); - else throw new Error('plugin-approvals file is full'); + if (oldestPendingIdx >= 0) { + const [dropped] = file.entries.splice(oldestPendingIdx, 1); + await deleteApprovalBundle(dropped.pluginId, dropped.bundleHash); + } else { + throw new ApprovalLimitError('The approval queue is full; ask an administrator to clear old requests'); + } } const entry: ApprovalEntry = { pluginId, @@ -166,4 +200,7 @@ export async function revokeApproval(pluginId: string, bundleHash: string): Prom if (idx < 0) return; file.entries.splice(idx, 1); await flushToDisk(); + await deleteApprovalBundle(pluginId, bundleHash); } + +export class ApprovalLimitError extends Error {} diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 75e9b5055..43ab13347 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -141,6 +141,10 @@ export interface SettingsPolicy { /** Plugin IDs that are force-enabled (users cannot disable) */ forceEnabledPlugins: string[]; /** Plugin IDs that have been approved by admin (users can enable) */ + /** + * @deprecated Never had a writer, and approving an id approved any code + * under it. Approvals are per package now (lib/admin/plugin-approvals.ts). + */ approvedPlugins: string[]; /** Theme IDs that are force-enabled (users cannot deactivate) */ forceEnabledThemes: string[]; diff --git a/lib/extension-package.ts b/lib/extension-package.ts index 895abafc2..548cf2124 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -479,6 +479,29 @@ export function suspiciousCode(code: string): string[] { return SUSPICIOUS_JS_PATTERNS.filter(({ pattern }) => pattern.test(code)).map(({ label }) => label); } +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`; + } + return JSON.stringify(value); +} + +/** + * SHA-256 over the validated manifest and the code. An admin approval is + * pinned to this, so re-zipping the same code with more permissions or + * other origins needs a new approval. Same result in the browser and on + * the server. + */ +export async function packageHash(manifest: ValidatedPluginManifest, code: string): Promise { + const data = new TextEncoder().encode(`${canonicalJson(manifest)}\n${code}`); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); +} + export interface PluginPackage { manifest: ValidatedPluginManifest; code: string; diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index 69d77b455..b84dc8b33 100644 --- a/lib/plugin-sandbox/consent.ts +++ b/lib/plugin-sandbox/consent.ts @@ -12,6 +12,9 @@ export interface ConsentRequest { pluginId: string; pluginName: string; permissions: Permission[]; + /** Where the plugin may send data, shown under http:fetch / http:post. */ + httpOrigins?: string[]; + apiPostPaths?: string[]; resolve: (granted: boolean) => void; } @@ -28,9 +31,14 @@ function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } -export function requestConsent(pluginId: string, pluginName: string, permissions: Permission[]): Promise { +export function requestConsent( + pluginId: string, + pluginName: string, + permissions: Permission[], + targets: { httpOrigins?: string[]; apiPostPaths?: string[] } = {}, +): Promise { return new Promise((resolve) => { - queue.push({ id: uid(), pluginId, pluginName, permissions, resolve }); + queue.push({ id: uid(), pluginId, pluginName, permissions, ...targets, resolve }); notify(); }); } @@ -50,47 +58,3 @@ export function subscribe(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } - -// ─── Friendly labels for permission strings ─────────────────── - -const PERMISSION_LABELS: Record = { - 'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' }, - 'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' }, - 'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' }, - 'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' }, - 'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' }, - 'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them). This also lets the plugin read the full contents of every file you attach to a message, before it is sent.' }, - 'email:blob-write': { title: 'Replace raw message content', body: 'Replace the contents of a file you attached, after you attached it and before it is sent to the server (needed to encrypt attachments).' }, - 'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' }, - 'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' }, - 'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' }, - 'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' }, - 'contacts:write': { title: 'Modify your contacts', body: 'Create, edit, or delete contact entries.' }, - 'files:read': { title: 'Read your files', body: 'Browse files stored in your WebDAV folders.' }, - 'files:write': { title: 'Modify your files', body: 'Create, edit, rename, move, or delete files.' }, - 'identity:read': { title: 'Read your identities', body: 'Access the From addresses and signatures you send mail from.' }, - 'identity:write': { title: 'Modify your identities', body: 'Create, edit, or delete identities.' }, - 'filters:read': { title: 'Read your filters', body: 'Access your Sieve mail-filter rules.' }, - 'filters:write': { title: 'Modify your filters', body: 'Create, edit, or delete Sieve filter rules.' }, - 'tasks:read': { title: 'Read your tasks', body: 'Access your task list.' }, - 'tasks:write': { title: 'Modify your tasks', body: 'Create, edit, or delete tasks.' }, - 'templates:read': { title: 'Read your templates', body: 'Access stored mail templates.' }, - 'templates:write': { title: 'Modify your templates', body: 'Create, edit, or delete mail templates.' }, - 'smime:read': { title: 'Read your S/MIME state', body: 'Access information about installed S/MIME keys and certificates.' }, - 'vacation:read': { title: 'Read your vacation auto-reply', body: 'See the configured vacation auto-reply state.' }, - 'vacation:write': { title: 'Modify your vacation auto-reply',body: 'Create, change, or remove the vacation auto-reply.' }, - 'settings:read': { title: 'Read your settings', body: 'Access non-secret user preferences.' }, - 'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' }, - 'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' }, - 'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' }, - 'auth:emit': { title: 'Emit login events', body: 'Emit auth events such as logout.' }, - 'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' }, - 'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' }, - 'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' }, - 'ui:download-file': { title: 'Download files', body: 'Download custom files generated by the plugin.' }, - 'account:read': { title: 'Read your accounts', body: 'Access the list of accounts you have configured in the app.' }, -}; - -export function describePermission(perm: string): { title: string; body: string } { - return PERMISSION_LABELS[perm] ?? { title: perm, body: 'No description available.' }; -} diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts new file mode 100644 index 000000000..11482525d --- /dev/null +++ b/lib/plugin-sandbox/permission-text.ts @@ -0,0 +1,36 @@ +// How permissions are shown to people: the consent dialog and the plugin list +// in Settings. The text itself lives in the locale files under +// `plugins.permissions..{title,body}`. + +import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; + +/** + * Permissions that let a plugin read or send mail content, change what is + * sent or shown, run with full access, or move data off the device. The + * consent dialog lists them first and marks them. + */ +export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ + 'crypto:full', + 'email:send', + 'email:raw-send', + 'email:blob-read', + 'email:blob-write', + 'email:render-takeover', + 'http:fetch', + 'http:post', + 'filters:write', +]); + +/** Locale key segment for a permission: `email:read` → `email_read`. */ +export function permissionKey(perm: string): string { + return perm.replace(/[:-]/g, '_'); +} + +/** Permissions worth showing: known, not implicit, high-risk first. */ +export function visiblePermissions(perms: readonly string[]): string[] { + const known = new Set(ALL_PERMISSIONS); + const implicit = new Set(IMPLICIT_PERMISSIONS); + return [...new Set(perms)] + .filter((p) => known.has(p) && !implicit.has(p)) + .sort((a, b) => Number(HIGH_RISK_PERMISSIONS.has(b)) - Number(HIGH_RISK_PERMISSIONS.has(a))); +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 39695db07..57adaa37a 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -270,6 +270,15 @@ export interface InstalledPlugin { * skip the consent prompt (admin pre-approval). */ grantedPermissions?: string[]; + /** + * For plugins the user installed: the validated manifest, kept so the + * package can be rebuilt for an approval request, and the hash an admin + * approval is pinned to (see packageHash in lib/extension-package.ts). + */ + packageManifest?: Record; + approvalHash?: string; + /** Last answer from the server about this package's approval. */ + approvalStatus?: 'pending' | 'approved' | 'denied' | 'not-requested'; } // ─── UI Slots ──────────────────────────────────────────────── diff --git a/locales/ar/common.json b/locales/ar/common.json index b5526fea4..97cc217a3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "فشل التفويض: {error}", "missing_params": "رمز التفويض أو الحالة مفقود.", "delivery_failed": "تعذّر إرسال نتيجة التفويض إلى تبويب التطبيق. يرجى إغلاق هذه النافذة والمحاولة مرة أخرى." + }, + "settings": { + "title": "الإضافات", + "description": "إضافات أضافها المسؤول. فعّلها أو أوقفها لحسابك.", + "description_uploads": "تضيف الإضافات ميزات إلى تطبيق البريد. فعّلها أو أوقفها لحسابك، أو ثبّت إضافاتك الخاصة.", + "description_demo": "الإضافات غير متاحة في الوضع التجريبي.", + "install_button": "تثبيت إضافة", + "install_hint": "اختر ملف ZIP لإضافة.", + "install_hint_approval": "اختر ملف ZIP لإضافة. يجب أن يوافق عليها المسؤول قبل أن تتمكن من تفعيلها.", + "install_uploads_off": "لا يسمح المسؤول بتثبيت الإضافات.", + "install_managed_id": "يوفّر المسؤول بالفعل إضافة بهذا الاسم، لذا لا يمكن استبدالها.", + "install_invalid": "هذا الملف ليس إضافة قابلة للتثبيت: {details}", + "toast_installed": "تم تثبيت الإضافة. فعّلها أدناه.", + "toast_installed_pending": "تم تثبيت الإضافة وإرسالها إلى المسؤول للموافقة عليها.", + "toast_enabled": "تم تفعيل {name}", + "toast_disabled": "تم إيقاف {name}", + "toast_removed": "تمت إزالة {name}", + "toast_forced": "يُبقي المسؤول {name} مفعّلة", + "toast_approval_requested": "{name} بانتظار موافقة المسؤول", + "empty_title": "لا توجد إضافات بعد", + "empty_admin": "لم يُضف المسؤول أي إضافات.", + "empty_uploads": "ثبّت إضافة من ملف ZIP للبدء.", + "empty_demo": "تحتاج الإضافات إلى حساب حقيقي، لذا لا يتضمن الوضع التجريبي أيًّا منها.", + "syncing": "جارٍ التحقق من الإضافات مع الخادم…", + "status_installed": "متوقفة", + "status_enabled": "جارٍ التشغيل", + "status_running": "مفعّلة", + "status_disabled": "متوقفة", + "status_error": "مشكلة", + "badge_forced": "مطلوبة", + "badge_managed": "من المسؤول", + "badge_yours": "ثبّتها أنت", + "badge_awaiting": "بانتظار الموافقة", + "toggle_label": "تفعيل {name} أو إيقافها", + "forced_note": "يشترط المسؤول هذه الإضافة، لذا لا يمكن إيقافها.", + "permissions": "ما يمكنها فعله", + "permissions_managed": "ما يمكنها فعله (بموافقة المسؤول)", + "settings": "الإعدادات", + "remove": "إزالة", + "remove_title": "إزالة {name}؟", + "remove_message": "ستُحذف الإضافة وكل ما خزّنته في هذا المتصفح. يمكنك تثبيتها مرة أخرى لاحقًا.", + "remove_confirm": "إزالة", + "error_user_plugins_off": "أوقف المسؤول الإضافات التي يثبّتها المستخدمون، لذا لا تعمل هذه الإضافة.", + "error_approval_unavailable": "ثُبّتت هذه الإضافة قبل أن تصبح الموافقة مطلوبة. أزلها وثبّتها مرة أخرى لإرسالها للموافقة.", + "error_approval_denied": "لم يوافق المسؤول على هذه الإضافة.", + "error_approval_pending": "بانتظار موافقة المسؤول على هذه الإضافة." + }, + "consent": { + "title": "هل تسمح لـ {name} بذلك؟", + "intro": "تطلب الإضافة الأذونات أدناه. لن تحصل عليها إلا إذا سمحت بذلك.", + "high_risk_note": "العناصر المميّزة تمنح الإضافة وصولًا إلى محتوى بريدك أو تتيح لها إرسال البيانات إلى جهات أخرى. لا تسمح بها إلا للإضافات التي تثق بها.", + "revoke_hint": "يمكنك إيقاف الإضافة أو إزالتها من الإعدادات في أي وقت.", + "deny": "عدم السماح", + "allow": "سماح" + }, + "permissions": { + "none": "لا تحتاج إلى أذونات خاصة.", + "high_risk": "تتطلب الحذر", + "email_read": { + "title": "قراءة بريدك", + "body": "عرض المرسلين والمستلمين والمواضيع والنص الكامل للرسائل التي تفتحها ولقائمة الرسائل." + }, + "email_write": { + "title": "تغيير الوسوم والعلامات على بريدك", + "body": "إضافة الوسوم والعلامات والكلمات المفتاحية الأخرى إلى رسائلك أو إزالتها." + }, + "email_send": { + "title": "تغيير البريد قبل إرساله", + "body": "قراءة وتغيير المستلمين والموضوع والنص والمرفقات في الرسائل التي تكتبها، ومنع إرسال رسالة." + }, + "crypto_full": { + "title": "وصول كامل إلى هذا التطبيق", + "body": "تعمل بالصلاحيات نفسها التي يملكها تطبيق البريد. يمكنها قراءة كل بريدك ومفاتيحك المخزّنة والتصرف نيابةً عنك. لا تسمح بذلك إلا للإضافات التي تثق بها تمامًا." + }, + "email_raw_send": { + "title": "إرسال رسائل أنشأتها بنفسها", + "body": "إرسال رسائل كاملة من حسابك، مثل الرسائل الموقّعة أو المشفّرة." + }, + "email_blob_read": { + "title": "قراءة المحتوى الخام للرسائل", + "body": "قراءة البيانات الأصلية لرسائلك ومرفقاتها، ولكل ملف ترفقه قبل إرساله." + }, + "email_blob_write": { + "title": "استبدال المرفقات قبل الإرسال", + "body": "رفع ملفات إلى خادم البريد واستبدال ملف أرفقته بملف آخر قبل إرسال الرسالة." + }, + "email_render_takeover": { + "title": "تغيير طريقة عرض الرسائل", + "body": "استبدال نص الرسائل التي تفتحها ونص قائمة الرسائل، مثلًا لعرض محتوى مفكوك التشفير." + }, + "calendar_read": { + "title": "قراءة تقويمك", + "body": "عرض تقويماتك وأحداثها." + }, + "calendar_write": { + "title": "تغيير تقويمك", + "body": "إنشاء الأحداث وتغييرها وحذفها." + }, + "contacts_read": { + "title": "قراءة جهات اتصالك", + "body": "البحث في دفاتر العناوين وقراءتها." + }, + "contacts_write": { + "title": "تغيير جهات اتصالك", + "body": "إنشاء جهات الاتصال ودفاتر العناوين وتغييرها وحذفها." + }, + "files_read": { + "title": "قراءة ملفاتك", + "body": "تصفّح الملفات المخزّنة في حسابك وفتحها." + }, + "files_write": { + "title": "تغيير ملفاتك", + "body": "رفع الملفات في حسابك وإعادة تسميتها ونقلها وحذفها." + }, + "identity_read": { + "title": "عرض عناوين المرسل", + "body": "عرض العناوين التي ترسل منها وتوقيعاتها." + }, + "identity_write": { + "title": "تغيير عناوين المرسل", + "body": "إنشاء عناوين المرسل والتوقيعات وتغييرها وحذفها." + }, + "filters_read": { + "title": "قراءة مرشحات البريد", + "body": "عرض القواعد التي تفرز بريدك الوارد." + }, + "filters_write": { + "title": "تغيير مرشحات البريد", + "body": "إضافة قواعد إلى المرشحات على الخادم. يمكن لهذه القواعد نقل البريد الوارد أو إعادة توجيهه أو حذفه." + }, + "tasks_read": { + "title": "قراءة مهامك", + "body": "عرض قوائم المهام والمهام." + }, + "tasks_write": { + "title": "تغيير مهامك", + "body": "إنشاء المهام وتغييرها وإكمالها وحذفها." + }, + "templates_read": { + "title": "قراءة قوالبك", + "body": "عرض قوالب الرسائل المحفوظة." + }, + "templates_write": { + "title": "تغيير قوالبك", + "body": "إنشاء قوالب الرسائل وتغييرها وحذفها." + }, + "smime_read": { + "title": "عرض حالة S/MIME", + "body": "معرفة ما إذا كانت لديك مفاتيح وشهادات S/MIME، دون الوصول إلى المفاتيح." + }, + "vacation_read": { + "title": "عرض الرد التلقائي أثناء الغياب", + "body": "معرفة ما إذا كان الرد التلقائي مفعّلًا وما يقوله." + }, + "vacation_write": { + "title": "تغيير الرد التلقائي أثناء الغياب", + "body": "تفعيل الرد التلقائي أو إيقافه وتغيير نصه وتواريخه." + }, + "settings_read": { + "title": "قراءة إعداداتك", + "body": "عرض تفضيلاتك وتعريفات الوسوم." + }, + "settings_write": { + "title": "تغيير إعداداتك", + "body": "إضافة الوسوم وإعادة ترتيبها وتغيير تفضيلاتك." + }, + "security_read": { + "title": "عرض أمان حسابك", + "body": "معرفة ما إذا كانت المصادقة الثنائية والتشفير مفعّلين، دون الوصول إلى البيانات السرية." + }, + "auth_observe": { + "title": "معرفة وقت تسجيل دخولك", + "body": "تلقّي إشعار عند تسجيل دخولك أو تبديل الحسابات أو تجديد جلستك." + }, + "auth_emit": { + "title": "تسجيل خروجك", + "body": "تسجيل خروجك من تطبيق البريد." + }, + "account_read": { + "title": "عرض حساباتك", + "body": "عرض الحسابات التي أضفتها، مع عناوينها وخوادمها." + }, + "http_post": { + "title": "استخدام خادم البريد نيابةً عنك", + "body": "استدعاء نقاط النهاية هذه على الخادم ببيانات دخولك:" + }, + "http_fetch": { + "title": "الاتصال بمواقع أخرى", + "body": "إرسال طلبات إلى هذه المواقع. قد يُرسل إليها أي شيء تستطيع الإضافة قراءته:" + }, + "admin_config": { + "title": "قراءة إعداداتها من المسؤول", + "body": "قراءة الإعدادات التي حفظها المسؤول لهذه الإضافة." + }, + "ui_observe": { + "title": "تتبّع موقعك في التطبيق", + "body": "معرفة الصفحة المفتوحة والتفاعل مع الروابط التي تنقر عليها." + }, + "ui_toolbar": { + "title": "إضافة أزرار إلى شريط أدوات الرسالة", + "body": "عرض أزرارها الخاصة فوق الرسالة المفتوحة." + }, + "ui_app_top_banner": { + "title": "عرض شريط في الأعلى", + "body": "عرض إشعار على امتداد أعلى التطبيق." + }, + "ui_email_banner": { + "title": "عرض إشعارات على الرسائل", + "body": "عرض إشعار فوق نص الرسالة المفتوحة." + }, + "ui_email_footer": { + "title": "إضافة محتوى أسفل الرسائل", + "body": "عرض محتواها الخاص أسفل الرسالة المفتوحة." + }, + "ui_contact_cryptokeys": { + "title": "عرض مفاتيح تشفير جهات الاتصال", + "body": "عرض مفاتيح التشفير لجهة اتصال وإدارتها." + }, + "ui_email_details": { + "title": "إضافة لوحات إلى تفاصيل الرسالة", + "body": "عرض لوحتها الخاصة بجانب الرسالة المفتوحة أو أسفلها." + }, + "ui_download_file": { + "title": "حفظ الملفات على جهازك", + "body": "إتاحة ملفات أنشأتها للتنزيل." + }, + "ui_message_list_tabs": { + "title": "إضافة علامات تبويب إلى البريد الوارد", + "body": "فرز البريد الوارد في علامات تبويب مثل التحديثات أو العروض الترويجية." + }, + "ui_composer_toolbar": { + "title": "إضافة أزرار إلى محرر الرسائل", + "body": "عرض أزرارها الخاصة أثناء كتابة رسالة." + }, + "ui_composer_sidebar": { + "title": "إضافة لوحة إلى محرر الرسائل", + "body": "عرض لوحتها الخاصة بجانب الرسالة التي تكتبها." + }, + "ui_sidebar_widget": { + "title": "إضافة مربع إلى الشريط الجانبي", + "body": "عرض محتواها الخاص في الشريط الجانبي للبريد." + }, + "ui_settings_section": { + "title": "إضافة صفحة إعدادات", + "body": "عرض صفحتها الخاصة في الإعدادات." + }, + "ui_context_menu": { + "title": "الإضافة إلى قائمة الرسالة", + "body": "إضافة عناصر إلى القائمة التي تظهر عند النقر بالزر الأيمن على رسالة." + }, + "ui_navigation_rail": { + "title": "الإضافة إلى شريط التنقل", + "body": "عرض زرها الخاص في شريط التنقل." + }, + "ui_keyboard": { + "title": "إضافة اختصارات لوحة المفاتيح", + "body": "الاستجابة لاختصارات لوحة المفاتيح التي تحددها." + }, + "ui_calendar_action": { + "title": "إضافة أزرار إلى الأحداث", + "body": "عرض أزرارها الخاصة عند إنشاء حدث أو تحريره." + }, + "ui_admin_page": { + "title": "إضافة صفحة إدارة", + "body": "عرض صفحتها الخاصة في إعدادات الإدارة." + }, + "app_lifecycle": { + "title": "معرفة وقت بدء التطبيق وتوقفه", + "body": "تلقّي إشعار عند بدء التطبيق أو انقطاع اتصاله أو تسجيل خروجك." + } } }, "global_search": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 3e0d3b434..d4940d31f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Ha fallat lautorització: {error}", "missing_params": "Falta el codi dautorització o lestat.", "delivery_failed": "No sha pogut lliurar el resultat de lautorització a la pestanya de laplicació. Tanqueu aquesta finestra i torneu-ho a provar." + }, + "settings": { + "title": "Connectors", + "description": "Connectors que ha afegit l'administrador. Activeu-los o desactiveu-los per al vostre compte.", + "description_uploads": "Els connectors afegeixen funcions al webmail. Activeu-los o desactiveu-los per al vostre compte, o instal·leu-ne de propis.", + "description_demo": "Els connectors no estan disponibles a la demo.", + "install_button": "Instal·la un connector", + "install_hint": "Trieu un fitxer ZIP de connector.", + "install_hint_approval": "Trieu un fitxer ZIP de connector. Un administrador l'ha d'aprovar abans que el pugueu activar.", + "install_uploads_off": "L'administrador no permet instal·lar connectors.", + "install_managed_id": "L'administrador ja proporciona un connector amb aquest nom, així que no es pot substituir.", + "install_invalid": "Aquest fitxer no és un connector que es pugui instal·lar: {details}", + "toast_installed": "S'ha instal·lat el connector. Activeu-lo a continuació.", + "toast_installed_pending": "S'ha instal·lat el connector i s'ha enviat a l'administrador perquè l'aprovi.", + "toast_enabled": "{name} està activat", + "toast_disabled": "{name} està desactivat", + "toast_removed": "S'ha eliminat {name}", + "toast_forced": "L'administrador manté {name} activat", + "toast_approval_requested": "{name} està pendent de l'aprovació de l'administrador", + "empty_title": "Encara no hi ha connectors", + "empty_admin": "L'administrador no ha afegit cap connector.", + "empty_uploads": "Per començar, instal·leu un connector des d'un fitxer ZIP.", + "empty_demo": "Els connectors necessiten un compte real, així que la demo no en té cap.", + "syncing": "S'estan comprovant els connectors amb el servidor…", + "status_installed": "Desactivat", + "status_enabled": "S'està iniciant", + "status_running": "Activat", + "status_disabled": "Desactivat", + "status_error": "Problema", + "badge_forced": "Obligatori", + "badge_managed": "De l'administrador", + "badge_yours": "Instal·lat per vós", + "badge_awaiting": "Pendent d'aprovació", + "toggle_label": "Activa o desactiva {name}", + "forced_note": "L'administrador requereix aquest connector, així que no es pot desactivar.", + "permissions": "Què pot fer", + "permissions_managed": "Què pot fer (aprovat per l'administrador)", + "settings": "Configuració", + "remove": "Elimina", + "remove_title": "Voleu eliminar {name}?", + "remove_message": "S'eliminaran el connector i tot el que ha desat en aquest navegador. El podeu tornar a instal·lar més endavant.", + "remove_confirm": "Elimina", + "error_user_plugins_off": "L'administrador ha desactivat els connectors instal·lats pels usuaris, així que aquest connector no s'executa.", + "error_approval_unavailable": "Aquest connector es va instal·lar abans que calgués l'aprovació. Elimineu-lo i torneu-lo a instal·lar per enviar-lo a aprovar.", + "error_approval_denied": "L'administrador no ha aprovat aquest connector.", + "error_approval_pending": "S'espera que l'administrador aprovi aquest connector." + }, + "consent": { + "title": "Voleu permetre que {name} faci això?", + "intro": "El connector demana els permisos següents. Només els obté si ho permeteu.", + "high_risk_note": "Els elements marcats donen al connector accés al contingut del vostre correu o li permeten enviar dades a altres llocs. Permeteu-los només als connectors de confiança.", + "revoke_hint": "Podeu desactivar o eliminar el connector a Configuració en qualsevol moment.", + "deny": "No ho permetis", + "allow": "Permet" + }, + "permissions": { + "none": "No necessita cap permís especial.", + "high_risk": "Cal anar amb compte", + "email_read": { + "title": "Llegir el vostre correu", + "body": "Veure remitents, destinataris, assumptes i el text complet dels missatges que obriu i de la llista de missatges." + }, + "email_write": { + "title": "Canviar les etiquetes i marques del correu", + "body": "Afegir o treure etiquetes, marques i altres paraules clau dels vostres missatges." + }, + "email_send": { + "title": "Canviar el correu abans d'enviar-lo", + "body": "Llegir i canviar els destinataris, l'assumpte, el text i els adjunts dels missatges que escriviu, i impedir que s'enviï un missatge." + }, + "crypto_full": { + "title": "Accés complet a aquesta aplicació", + "body": "S'executa amb els mateixos drets que el mateix webmail. Pot llegir tot el vostre correu i les claus desades i actuar en nom vostre. Permeteu-ho només als connectors en què confieu plenament." + }, + "email_raw_send": { + "title": "Enviar missatges que ha creat ell mateix", + "body": "Enviar missatges complets, per exemple signats o xifrats, des del vostre compte." + }, + "email_blob_read": { + "title": "Llegir el contingut original dels missatges", + "body": "Llegir les dades originals dels vostres missatges i adjunts, i de cada fitxer que adjunteu abans d'enviar-lo." + }, + "email_blob_write": { + "title": "Substituir adjunts abans d'enviar", + "body": "Pujar fitxers al vostre servidor de correu i substituir un fitxer adjunt per un altre abans que s'enviï el missatge." + }, + "email_render_takeover": { + "title": "Canviar com es mostren els missatges", + "body": "Substituir el text dels missatges que obriu i de la llista de missatges, per exemple per mostrar contingut desxifrat." + }, + "calendar_read": { + "title": "Llegir el vostre calendari", + "body": "Veure els vostres calendaris i els seus esdeveniments." + }, + "calendar_write": { + "title": "Canviar el vostre calendari", + "body": "Crear, canviar i suprimir esdeveniments." + }, + "contacts_read": { + "title": "Llegir els vostres contactes", + "body": "Cercar i llegir les vostres llibretes d'adreces." + }, + "contacts_write": { + "title": "Canviar els vostres contactes", + "body": "Crear, canviar i suprimir contactes i llibretes d'adreces." + }, + "files_read": { + "title": "Llegir els vostres fitxers", + "body": "Navegar pels fitxers desats al vostre compte i obrir-los." + }, + "files_write": { + "title": "Canviar els vostres fitxers", + "body": "Pujar, canviar el nom, moure i suprimir fitxers del vostre compte." + }, + "identity_read": { + "title": "Veure les vostres identitats d'enviament", + "body": "Veure les adreces des de les quals envieu i les seves signatures." + }, + "identity_write": { + "title": "Canviar les vostres identitats d'enviament", + "body": "Crear, canviar i suprimir identitats d'enviament i signatures." + }, + "filters_read": { + "title": "Llegir els vostres filtres de correu", + "body": "Veure les regles que classifiquen el correu entrant." + }, + "filters_write": { + "title": "Canviar els vostres filtres de correu", + "body": "Afegir regles als filtres del servidor. Aquestes regles poden moure, reenviar o suprimir el correu entrant." + }, + "tasks_read": { + "title": "Llegir les vostres tasques", + "body": "Veure les vostres llistes de tasques i les tasques." + }, + "tasks_write": { + "title": "Canviar les vostres tasques", + "body": "Crear, canviar, completar i suprimir tasques." + }, + "templates_read": { + "title": "Llegir les vostres plantilles", + "body": "Veure les plantilles de missatge desades." + }, + "templates_write": { + "title": "Canviar les vostres plantilles", + "body": "Crear, canviar i suprimir plantilles de missatge." + }, + "smime_read": { + "title": "Veure l'estat de S/MIME", + "body": "Veure si teniu claus i certificats S/MIME, sense accés a les claus." + }, + "vacation_read": { + "title": "Veure la resposta automàtica", + "body": "Veure si la resposta automàtica està activada i què diu." + }, + "vacation_write": { + "title": "Canviar la resposta automàtica", + "body": "Activar o desactivar la resposta automàtica i canviar-ne el text i les dates." + }, + "settings_read": { + "title": "Llegir la vostra configuració", + "body": "Veure les vostres preferències i les definicions d'etiquetes." + }, + "settings_write": { + "title": "Canviar la vostra configuració", + "body": "Afegir i reordenar etiquetes i canviar les vostres preferències." + }, + "security_read": { + "title": "Veure la seguretat del compte", + "body": "Veure si l'autenticació de dos factors i el xifratge estan activats, sense accés als secrets." + }, + "auth_observe": { + "title": "Saber quan inicieu la sessió", + "body": "Rebre un avís quan inicieu la sessió, canvieu de compte o es renova la sessió." + }, + "auth_emit": { + "title": "Tancar la vostra sessió", + "body": "Tancar la vostra sessió al webmail." + }, + "account_read": { + "title": "Veure els vostres comptes", + "body": "Veure els comptes que heu afegit, amb les seves adreces i servidors." + }, + "http_post": { + "title": "Fer servir el servidor del webmail en nom vostre", + "body": "Cridar aquests punts d'accés del servidor amb el vostre inici de sessió:" + }, + "http_fetch": { + "title": "Connectar-se a altres llocs web", + "body": "Enviar peticions a aquests llocs. Qualsevol cosa que el connector pugui llegir s'hi pot enviar:" + }, + "admin_config": { + "title": "Llegir la configuració que li ha posat l'administrador", + "body": "Llegir la configuració que l'administrador ha desat per a aquest connector." + }, + "ui_observe": { + "title": "Seguir on sou dins l'aplicació", + "body": "Saber quina pàgina està oberta i reaccionar als enllaços en què feu clic." + }, + "ui_toolbar": { + "title": "Afegir botons a la barra d'eines del missatge", + "body": "Mostrar botons propis a sobre d'un missatge obert." + }, + "ui_app_top_banner": { + "title": "Mostrar un bàner a dalt", + "body": "Mostrar un avís a la part superior de l'aplicació." + }, + "ui_email_banner": { + "title": "Mostrar avisos als missatges", + "body": "Mostrar un avís a sobre del text d'un missatge obert." + }, + "ui_email_footer": { + "title": "Afegir contingut sota els missatges", + "body": "Mostrar contingut propi a sota d'un missatge obert." + }, + "ui_contact_cryptokeys": { + "title": "Mostrar les claus de xifratge dels contactes", + "body": "Mostrar i gestionar les claus de xifratge d'un contacte." + }, + "ui_email_details": { + "title": "Afegir panells als detalls del missatge", + "body": "Mostrar un panell propi al costat o a sota d'un missatge obert." + }, + "ui_download_file": { + "title": "Desar fitxers al dispositiu", + "body": "Oferir per baixar fitxers que ha creat." + }, + "ui_message_list_tabs": { + "title": "Afegir pestanyes a la safata d'entrada", + "body": "Classificar la safata d'entrada en pestanyes com ara Actualitzacions o Promocions." + }, + "ui_composer_toolbar": { + "title": "Afegir botons al redactor", + "body": "Mostrar botons propis mentre escriviu un missatge." + }, + "ui_composer_sidebar": { + "title": "Afegir un panell al redactor", + "body": "Mostrar un panell propi al costat del missatge que escriviu." + }, + "ui_sidebar_widget": { + "title": "Afegir un quadre a la barra lateral", + "body": "Mostrar contingut propi a la barra lateral del correu." + }, + "ui_settings_section": { + "title": "Afegir una pàgina de configuració", + "body": "Mostrar una pàgina pròpia a Configuració." + }, + "ui_context_menu": { + "title": "Afegir elements al menú del missatge", + "body": "Afegir elements al menú que s'obre quan feu clic amb el botó dret en un missatge." + }, + "ui_navigation_rail": { + "title": "Afegir elements a la barra de navegació", + "body": "Mostrar un botó propi a la barra de navegació." + }, + "ui_keyboard": { + "title": "Afegir dreceres de teclat", + "body": "Reaccionar a les dreceres de teclat que defineix." + }, + "ui_calendar_action": { + "title": "Afegir botons als esdeveniments", + "body": "Mostrar botons propis quan creeu o editeu un esdeveniment." + }, + "ui_admin_page": { + "title": "Afegir una pàgina d'administració", + "body": "Mostrar una pàgina pròpia a la configuració d'administració." + }, + "app_lifecycle": { + "title": "Saber quan l'aplicació s'inicia i s'atura", + "body": "Rebre un avís quan l'aplicació s'inicia, es queda sense connexió o tanqueu la sessió." + } } }, "global_search": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 2bcc4524d..677cc2e21 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorizace se nezdařila: {error}", "missing_params": "Chybí autorizační kód nebo stav.", "delivery_failed": "Výsledek autorizace se nepodařilo předat kartě aplikace. Zavřete toto okno a zkuste to znovu." + }, + "settings": { + "title": "Pluginy", + "description": "Pluginy přidané vaším správcem. Pro svůj účet je můžete zapnout nebo vypnout.", + "description_uploads": "Pluginy přidávají do webmailu další funkce. Pro svůj účet je můžete zapnout nebo vypnout, případně nainstalovat vlastní.", + "description_demo": "Pluginy nejsou v ukázkové verzi k dispozici.", + "install_button": "Nainstalovat plugin", + "install_hint": "Vyberte soubor ZIP s pluginem.", + "install_hint_approval": "Vyberte soubor ZIP s pluginem. Než ho budete moci zapnout, musí ho schválit správce.", + "install_uploads_off": "Váš správce nepovoluje instalaci pluginů.", + "install_managed_id": "Váš správce už poskytuje plugin s tímto názvem, proto ho nelze nahradit.", + "install_invalid": "Tento soubor není plugin, který lze nainstalovat: {details}", + "toast_installed": "Plugin nainstalován. Zapněte ho níže.", + "toast_installed_pending": "Plugin nainstalován a odeslán správci ke schválení.", + "toast_enabled": "Plugin {name} je zapnutý", + "toast_disabled": "Plugin {name} je vypnutý", + "toast_removed": "Plugin {name} byl odstraněn", + "toast_forced": "Váš správce ponechává plugin {name} zapnutý", + "toast_approval_requested": "Plugin {name} čeká na schválení správcem", + "empty_title": "Zatím žádné pluginy", + "empty_admin": "Váš správce zatím nepřidal žádné pluginy.", + "empty_uploads": "Začněte instalací pluginu ze souboru ZIP.", + "empty_demo": "Pluginy vyžadují skutečný účet, proto je ukázková verze nemá.", + "syncing": "Kontrola pluginů na serveru…", + "status_installed": "Vypnuto", + "status_enabled": "Spouští se", + "status_running": "Zapnuto", + "status_disabled": "Vypnuto", + "status_error": "Problém", + "badge_forced": "Povinný", + "badge_managed": "Od správce", + "badge_yours": "Nainstalováno vámi", + "badge_awaiting": "Čeká na schválení", + "toggle_label": "Zapnout nebo vypnout plugin {name}", + "forced_note": "Váš správce tento plugin vyžaduje, proto ho nelze vypnout.", + "permissions": "Co může dělat", + "permissions_managed": "Co může dělat (schváleno správcem)", + "settings": "Nastavení", + "remove": "Odstranit", + "remove_title": "Odstranit plugin {name}?", + "remove_message": "Plugin i vše, co uložil v tomto prohlížeči, bude smazáno. Později ho můžete nainstalovat znovu.", + "remove_confirm": "Odstranit", + "error_user_plugins_off": "Váš správce vypnul pluginy instalované uživateli, proto tento plugin neběží.", + "error_approval_unavailable": "Tento plugin byl nainstalován dříve, než bylo vyžadováno schválení. Odstraňte ho a nainstalujte znovu, aby se odeslal ke schválení.", + "error_approval_denied": "Váš správce tento plugin neschválil.", + "error_approval_pending": "Čeká se, až správce tento plugin schválí." + }, + "consent": { + "title": "Povolit pluginu {name} tyto akce?", + "intro": "Plugin žádá o níže uvedená oprávnění. Dostane je, jen pokud je povolíte.", + "high_risk_note": "Označené položky dávají pluginu přístup k obsahu vaší pošty nebo mu umožňují posílat data jinam. Povolte je jen pluginům, kterým důvěřujete.", + "revoke_hint": "Plugin můžete kdykoli vypnout nebo odstranit v Nastavení.", + "deny": "Nepovolit", + "allow": "Povolit" + }, + "permissions": { + "none": "Nepotřebuje žádná zvláštní oprávnění.", + "high_risk": "Vyžaduje opatrnost", + "email_read": { + "title": "Číst vaši poštu", + "body": "Vidí odesílatele, příjemce, předměty a celý text zpráv, které otevřete, i seznamu zpráv." + }, + "email_write": { + "title": "Měnit štítky a příznaky pošty", + "body": "Přidává nebo odebírá štítky, příznaky a další klíčová slova u vašich zpráv." + }, + "email_send": { + "title": "Měnit poštu před odesláním", + "body": "Čte a mění příjemce, předmět, text a přílohy zpráv, které píšete, a může zastavit odeslání zprávy." + }, + "crypto_full": { + "title": "Plný přístup k této aplikaci", + "body": "Běží se stejnými právy jako samotný webmail. Může číst veškerou vaši poštu a uložené klíče a jednat vaším jménem. Povolte to jen pluginům, kterým plně důvěřujete." + }, + "email_raw_send": { + "title": "Odesílat zprávy, které sám vytvořil", + "body": "Odesílá z vašeho účtu kompletní zprávy, například podepsané nebo šifrované." + }, + "email_blob_read": { + "title": "Číst nezpracovaný obsah zpráv", + "body": "Čte původní data vašich zpráv a příloh i každého souboru, který připojíte před odesláním." + }, + "email_blob_write": { + "title": "Nahrazovat přílohy před odesláním", + "body": "Nahrává soubory na váš poštovní server a před odesláním zprávy nahradí připojený soubor jiným." + }, + "email_render_takeover": { + "title": "Měnit zobrazení zpráv", + "body": "Nahrazuje text zpráv, které otevřete, i seznamu zpráv, například aby zobrazil dešifrovaný obsah." + }, + "calendar_read": { + "title": "Číst váš kalendář", + "body": "Vidí vaše kalendáře a jejich události." + }, + "calendar_write": { + "title": "Měnit váš kalendář", + "body": "Vytváří, mění a maže události." + }, + "contacts_read": { + "title": "Číst vaše kontakty", + "body": "Prohledává a čte vaše adresáře." + }, + "contacts_write": { + "title": "Měnit vaše kontakty", + "body": "Vytváří, mění a maže kontakty a adresáře." + }, + "files_read": { + "title": "Číst vaše soubory", + "body": "Prochází a otevírá soubory uložené ve vašem účtu." + }, + "files_write": { + "title": "Měnit vaše soubory", + "body": "Nahrává, přejmenovává, přesouvá a maže soubory ve vašem účtu." + }, + "identity_read": { + "title": "Vidět vaše identity odesílatele", + "body": "Vidí adresy, ze kterých odesíláte, a jejich podpisy." + }, + "identity_write": { + "title": "Měnit vaše identity odesílatele", + "body": "Vytváří, mění a maže identity odesílatele a podpisy." + }, + "filters_read": { + "title": "Číst vaše filtry pošty", + "body": "Vidí pravidla, která třídí vaši příchozí poštu." + }, + "filters_write": { + "title": "Měnit vaše filtry pošty", + "body": "Přidává pravidla do filtrů na serveru. Taková pravidla mohou příchozí poštu přesouvat, přeposílat nebo mazat." + }, + "tasks_read": { + "title": "Číst vaše úkoly", + "body": "Vidí vaše seznamy úkolů a úkoly." + }, + "tasks_write": { + "title": "Měnit vaše úkoly", + "body": "Vytváří, mění, dokončuje a maže úkoly." + }, + "templates_read": { + "title": "Číst vaše šablony", + "body": "Vidí vaše uložené šablony zpráv." + }, + "templates_write": { + "title": "Měnit vaše šablony", + "body": "Vytváří, mění a maže šablony zpráv." + }, + "smime_read": { + "title": "Vidět stav S/MIME", + "body": "Vidí, zda máte klíče a certifikáty S/MIME, bez přístupu ke klíčům." + }, + "vacation_read": { + "title": "Vidět automatickou odpověď v nepřítomnosti", + "body": "Vidí, zda je automatická odpověď zapnutá a co obsahuje." + }, + "vacation_write": { + "title": "Měnit automatickou odpověď v nepřítomnosti", + "body": "Zapíná a vypíná automatickou odpověď a mění její text a data." + }, + "settings_read": { + "title": "Číst vaše nastavení", + "body": "Vidí vaše předvolby a definice štítků." + }, + "settings_write": { + "title": "Měnit vaše nastavení", + "body": "Přidává štítky, mění jejich pořadí a mění vaše předvolby." + }, + "security_read": { + "title": "Vidět zabezpečení účtu", + "body": "Vidí, zda je zapnuté dvoufázové ověření a šifrování, bez přístupu k tajným údajům." + }, + "auth_observe": { + "title": "Vědět, kdy se přihlásíte", + "body": "Dostává zprávu, když se přihlásíte, přepnete účet nebo se obnoví vaše relace." + }, + "auth_emit": { + "title": "Odhlásit vás", + "body": "Může vás odhlásit z webmailu." + }, + "account_read": { + "title": "Vidět vaše účty", + "body": "Vidí účty, které jste přidali, včetně jejich adres a serverů." + }, + "http_post": { + "title": "Používat server webmailu vaším jménem", + "body": "Volá tyto koncové body serveru s vaším přihlášením:" + }, + "http_fetch": { + "title": "Připojovat se k jiným webům", + "body": "Odesílá požadavky na tyto weby. Cokoli, co plugin může číst, tam může být odesláno:" + }, + "admin_config": { + "title": "Číst nastavení od správce", + "body": "Čte nastavení, která pro tento plugin uložil váš správce." + }, + "ui_observe": { + "title": "Sledovat, kde se v aplikaci nacházíte", + "body": "Ví, která stránka je otevřená, a reaguje na odkazy, na které kliknete." + }, + "ui_toolbar": { + "title": "Přidávat tlačítka na panel nástrojů zprávy", + "body": "Zobrazuje vlastní tlačítka nad otevřenou zprávou." + }, + "ui_app_top_banner": { + "title": "Zobrazovat banner nahoře", + "body": "Zobrazuje oznámení přes horní část aplikace." + }, + "ui_email_banner": { + "title": "Zobrazovat oznámení u zpráv", + "body": "Zobrazuje oznámení nad textem otevřené zprávy." + }, + "ui_email_footer": { + "title": "Přidávat obsah pod zprávy", + "body": "Zobrazuje vlastní obsah pod otevřenou zprávou." + }, + "ui_contact_cryptokeys": { + "title": "Zobrazovat šifrovací klíče kontaktů", + "body": "Zobrazuje a spravuje šifrovací klíče u kontaktu." + }, + "ui_email_details": { + "title": "Přidávat panely do podrobností zprávy", + "body": "Zobrazuje vlastní panel vedle otevřené zprávy nebo pod ní." + }, + "ui_download_file": { + "title": "Ukládat soubory do zařízení", + "body": "Nabízí ke stažení soubory, které vytvořil." + }, + "ui_message_list_tabs": { + "title": "Přidávat karty do Doručených", + "body": "Rozděluje Doručené do karet, například Aktualizace nebo Akce." + }, + "ui_composer_toolbar": { + "title": "Přidávat tlačítka do editoru zprávy", + "body": "Zobrazuje vlastní tlačítka, když píšete zprávu." + }, + "ui_composer_sidebar": { + "title": "Přidávat panel do editoru zprávy", + "body": "Zobrazuje vlastní panel vedle zprávy, kterou píšete." + }, + "ui_sidebar_widget": { + "title": "Přidávat pole do postranního panelu", + "body": "Zobrazuje vlastní obsah v postranním panelu pošty." + }, + "ui_settings_section": { + "title": "Přidávat stránku nastavení", + "body": "Zobrazuje vlastní stránku v Nastavení." + }, + "ui_context_menu": { + "title": "Přidávat položky do nabídky zprávy", + "body": "Přidává položky do nabídky, která se otevře po kliknutí pravým tlačítkem na zprávu." + }, + "ui_navigation_rail": { + "title": "Přidávat tlačítko do navigačního panelu", + "body": "Zobrazuje vlastní tlačítko v navigačním panelu." + }, + "ui_keyboard": { + "title": "Přidávat klávesové zkratky", + "body": "Reaguje na klávesové zkratky, které sám definuje." + }, + "ui_calendar_action": { + "title": "Přidávat tlačítka k událostem", + "body": "Zobrazuje vlastní tlačítka, když vytváříte nebo upravujete událost." + }, + "ui_admin_page": { + "title": "Přidávat stránku administrace", + "body": "Zobrazuje vlastní stránku v nastavení administrace." + }, + "app_lifecycle": { + "title": "Vědět, kdy se aplikace spouští a ukončuje", + "body": "Dostává zprávu, když se aplikace spustí, přejde do režimu offline nebo když se odhlásíte." + } } }, "global_search": { diff --git a/locales/da/common.json b/locales/da/common.json index 09b5c9dec..e6448e529 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Godkendelse mislykkedes: {error}", "missing_params": "Autorisationskode eller tilstand mangler.", "delivery_failed": "Resultatet af godkendelsen kunne ikke leveres til app-fanen. Luk dette vindue og prøv igen." + }, + "settings": { + "title": "Plugins", + "description": "Plugins, som din administrator har tilføjet. Slå dem til eller fra for din konto.", + "description_uploads": "Plugins tilføjer funktioner til webmailen. Slå dem til eller fra for din konto, eller installer dine egne.", + "description_demo": "Plugins er ikke tilgængelige i demoen.", + "install_button": "Installer plugin", + "install_hint": "Vælg en ZIP-fil med et plugin.", + "install_hint_approval": "Vælg en ZIP-fil med et plugin. En administrator skal godkende det, før du kan slå det til.", + "install_uploads_off": "Din administrator tillader ikke, at du installerer plugins.", + "install_managed_id": "Din administrator leverer allerede et plugin med dette navn, så det kan ikke erstattes.", + "install_invalid": "Filen er ikke et plugin, der kan installeres: {details}", + "toast_installed": "Plugin installeret. Slå det til herunder.", + "toast_installed_pending": "Plugin installeret og sendt til din administrator til godkendelse.", + "toast_enabled": "{name} er slået til", + "toast_disabled": "{name} er slået fra", + "toast_removed": "{name} er fjernet", + "toast_forced": "Din administrator holder {name} slået til", + "toast_approval_requested": "{name} venter på godkendelse fra din administrator", + "empty_title": "Ingen plugins endnu", + "empty_admin": "Din administrator har ikke tilføjet nogen plugins.", + "empty_uploads": "Installer et plugin fra en ZIP-fil for at komme i gang.", + "empty_demo": "Plugins kræver en rigtig konto, så demoen har ingen.", + "syncing": "Tjekker plugins med serveren…", + "status_installed": "Fra", + "status_enabled": "Starter", + "status_running": "Til", + "status_disabled": "Fra", + "status_error": "Problem", + "badge_forced": "Påkrævet", + "badge_managed": "Fra din administrator", + "badge_yours": "Installeret af dig", + "badge_awaiting": "Venter på godkendelse", + "toggle_label": "Slå {name} til eller fra", + "forced_note": "Din administrator kræver dette plugin, så det kan ikke slås fra.", + "permissions": "Hvad det kan", + "permissions_managed": "Hvad det kan (godkendt af din administrator)", + "settings": "Indstillinger", + "remove": "Fjern", + "remove_title": "Fjern {name}?", + "remove_message": "Pluginnet og alt, hvad det har gemt i denne browser, slettes. Du kan installere det igen senere.", + "remove_confirm": "Fjern", + "error_user_plugins_off": "Din administrator har slået plugins installeret af brugere fra, så dette plugin kører ikke.", + "error_approval_unavailable": "Dette plugin blev installeret, før godkendelse var påkrævet. Fjern det, og installer det igen for at sende det til godkendelse.", + "error_approval_denied": "Din administrator har ikke godkendt dette plugin.", + "error_approval_pending": "Venter på, at din administrator godkender dette plugin." + }, + "consent": { + "title": "Vil du tillade {name} at gøre dette?", + "intro": "Pluginnet beder om tilladelserne herunder. Det får dem kun, hvis du tillader det.", + "high_risk_note": "Markerede punkter giver pluginnet adgang til indholdet af dine e-mails eller lader det sende data andre steder hen. Tillad dem kun for plugins, du stoler på.", + "revoke_hint": "Du kan altid slå pluginnet fra eller fjerne det under Indstillinger.", + "deny": "Tillad ikke", + "allow": "Tillad" + }, + "permissions": { + "none": "Det kræver ingen særlige tilladelser.", + "high_risk": "Kræver omtanke", + "email_read": { + "title": "Læse dine e-mails", + "body": "Se afsendere, modtagere, emner og den fulde tekst i de beskeder, du åbner, og i beskedlisten." + }, + "email_write": { + "title": "Ændre tags og markeringer på dine e-mails", + "body": "Tilføje eller fjerne tags, markeringer og andre nøgleord på dine beskeder." + }, + "email_send": { + "title": "Ændre e-mails, før de sendes", + "body": "Læse og ændre modtagere, emne, tekst og vedhæftninger i de beskeder, du skriver, og forhindre, at en besked bliver sendt." + }, + "crypto_full": { + "title": "Fuld adgang til denne app", + "body": "Kører med de samme rettigheder som selve webmailen. Det kan læse alle dine e-mails og gemte nøgler og handle på dine vegne. Tillad det kun for plugins, du stoler fuldt ud på." + }, + "email_raw_send": { + "title": "Sende beskeder, det selv har lavet", + "body": "Sende færdige beskeder, f.eks. signerede eller krypterede, fra din konto." + }, + "email_blob_read": { + "title": "Læse beskeders rå indhold", + "body": "Læse de originale data i dine beskeder og vedhæftninger og i alle filer, du vedhæfter, før de sendes." + }, + "email_blob_write": { + "title": "Erstatte vedhæftninger før afsendelse", + "body": "Uploade filer til din mailserver og erstatte en vedhæftet fil med en anden, før beskeden sendes." + }, + "email_render_takeover": { + "title": "Ændre, hvordan beskeder vises", + "body": "Erstatte teksten i de beskeder, du åbner, og i beskedlisten, f.eks. for at vise dekrypteret indhold." + }, + "calendar_read": { + "title": "Læse din kalender", + "body": "Se dine kalendere og deres begivenheder." + }, + "calendar_write": { + "title": "Ændre din kalender", + "body": "Oprette, ændre og slette begivenheder." + }, + "contacts_read": { + "title": "Læse dine kontakter", + "body": "Søge i og læse dine adressebøger." + }, + "contacts_write": { + "title": "Ændre dine kontakter", + "body": "Oprette, ændre og slette kontakter og adressebøger." + }, + "files_read": { + "title": "Læse dine filer", + "body": "Gennemse og åbne de filer, der er gemt på din konto." + }, + "files_write": { + "title": "Ændre dine filer", + "body": "Uploade, omdøbe, flytte og slette filer på din konto." + }, + "identity_read": { + "title": "Se dine afsenderidentiteter", + "body": "Se de adresser, du sender fra, og deres signaturer." + }, + "identity_write": { + "title": "Ændre dine afsenderidentiteter", + "body": "Oprette, ændre og slette afsenderidentiteter og signaturer." + }, + "filters_read": { + "title": "Læse dine mailfiltre", + "body": "Se de regler, der sorterer din indgående mail." + }, + "filters_write": { + "title": "Ændre dine mailfiltre", + "body": "Tilføje regler til dine filtre på serveren. Sådanne regler kan flytte, videresende eller slette indgående mail." + }, + "tasks_read": { + "title": "Læse dine opgaver", + "body": "Se dine opgavelister og opgaver." + }, + "tasks_write": { + "title": "Ændre dine opgaver", + "body": "Oprette, ændre, fuldføre og slette opgaver." + }, + "templates_read": { + "title": "Læse dine skabeloner", + "body": "Se dine gemte beskedskabeloner." + }, + "templates_write": { + "title": "Ændre dine skabeloner", + "body": "Oprette, ændre og slette beskedskabeloner." + }, + "smime_read": { + "title": "Se din S/MIME-status", + "body": "Se, om du har S/MIME-nøgler og -certifikater, uden adgang til nøglerne." + }, + "vacation_read": { + "title": "Se dit fraværssvar", + "body": "Se, om dit automatiske svar er slået til, og hvad der står i det." + }, + "vacation_write": { + "title": "Ændre dit fraværssvar", + "body": "Slå dit automatiske svar til eller fra og ændre teksten og datoerne." + }, + "settings_read": { + "title": "Læse dine indstillinger", + "body": "Se dine præferencer og tagdefinitioner." + }, + "settings_write": { + "title": "Ændre dine indstillinger", + "body": "Tilføje og omarrangere tags og ændre dine præferencer." + }, + "security_read": { + "title": "Se din kontos sikkerhed", + "body": "Se, om to-faktor-godkendelse og kryptering er slået til, uden adgang til hemmeligheder." + }, + "auth_observe": { + "title": "Vide, når du logger ind", + "body": "Få besked, når du logger ind, skifter konto, eller din session fornyes." + }, + "auth_emit": { + "title": "Logge dig ud", + "body": "Logge dig ud af webmailen." + }, + "account_read": { + "title": "Se dine konti", + "body": "Se de konti, du har tilføjet, med deres adresser og servere." + }, + "http_post": { + "title": "Bruge webmail-serveren for dig", + "body": "Kalde disse server-endpoints med dit login:" + }, + "http_fetch": { + "title": "Forbinde til andre websteder", + "body": "Sende forespørgsler til disse websteder. Alt, hvad pluginnet kan læse, kan blive sendt dertil:" + }, + "admin_config": { + "title": "Læse sine indstillinger fra din administrator", + "body": "Læse de indstillinger, din administrator har gemt for dette plugin." + }, + "ui_observe": { + "title": "Følge med i, hvor du er i appen", + "body": "Vide, hvilken side der er åben, og reagere på links, du klikker på." + }, + "ui_toolbar": { + "title": "Tilføje knapper til beskedens værktøjslinje", + "body": "Vise sine egne knapper over en åben besked." + }, + "ui_app_top_banner": { + "title": "Vise et banner øverst", + "body": "Vise en meddelelse hen over toppen af appen." + }, + "ui_email_banner": { + "title": "Vise meddelelser på beskeder", + "body": "Vise en meddelelse over teksten i en åben besked." + }, + "ui_email_footer": { + "title": "Tilføje indhold under beskeder", + "body": "Vise sit eget indhold under en åben besked." + }, + "ui_contact_cryptokeys": { + "title": "Vise kontakters krypteringsnøgler", + "body": "Vise og administrere krypteringsnøglerne på en kontakt." + }, + "ui_email_details": { + "title": "Tilføje paneler til beskeddetaljer", + "body": "Vise sit eget panel ved siden af eller under en åben besked." + }, + "ui_download_file": { + "title": "Gemme filer på din enhed", + "body": "Tilbyde filer, det har lavet, til download." + }, + "ui_message_list_tabs": { + "title": "Tilføje faner til din indbakke", + "body": "Sortere din indbakke i faner som Opdateringer eller Kampagner." + }, + "ui_composer_toolbar": { + "title": "Tilføje knapper til komponisten", + "body": "Vise sine egne knapper, mens du skriver en besked." + }, + "ui_composer_sidebar": { + "title": "Tilføje et panel til komponisten", + "body": "Vise sit eget panel ved siden af den besked, du skriver." + }, + "ui_sidebar_widget": { + "title": "Tilføje en boks til sidepanelet", + "body": "Vise sit eget indhold i mailens sidepanel." + }, + "ui_settings_section": { + "title": "Tilføje en indstillingsside", + "body": "Vise sin egen side under Indstillinger." + }, + "ui_context_menu": { + "title": "Tilføje til beskedmenuen", + "body": "Tilføje punkter til den menu, der åbner, når du højreklikker på en besked." + }, + "ui_navigation_rail": { + "title": "Tilføje til navigationslinjen", + "body": "Vise sin egen knap på navigationslinjen." + }, + "ui_keyboard": { + "title": "Tilføje tastaturgenveje", + "body": "Reagere på tastaturgenveje, som det selv definerer." + }, + "ui_calendar_action": { + "title": "Tilføje knapper til begivenheder", + "body": "Vise sine egne knapper, når du opretter eller redigerer en begivenhed." + }, + "ui_admin_page": { + "title": "Tilføje en administratorside", + "body": "Vise sin egen side i administratorindstillingerne." + }, + "app_lifecycle": { + "title": "Vide, når appen starter og stopper", + "body": "Få besked, når appen starter, går offline, eller du logger ud." + } } }, "global_search": { diff --git a/locales/de/common.json b/locales/de/common.json index 06d33815f..3402006f8 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorisierung fehlgeschlagen: {error}", "missing_params": "Autorisierungscode oder State fehlt.", "delivery_failed": "Das Autorisierungsergebnis konnte nicht an den App-Tab übermittelt werden. Bitte schließen Sie dieses Fenster und versuchen Sie es erneut." + }, + "settings": { + "title": "Plugins", + "description": "Plugins, die Ihr Administrator hinzugefügt hat. Schalten Sie sie für Ihr Konto ein oder aus.", + "description_uploads": "Plugins erweitern das Webmail um Funktionen. Schalten Sie sie für Ihr Konto ein oder aus, oder installieren Sie eigene.", + "description_demo": "In der Demo sind keine Plugins verfügbar.", + "install_button": "Plugin installieren", + "install_hint": "Wählen Sie eine Plugin-ZIP-Datei.", + "install_hint_approval": "Wählen Sie eine Plugin-ZIP-Datei. Ein Administrator muss das Plugin freigeben, bevor Sie es einschalten können.", + "install_uploads_off": "Ihr Administrator erlaubt keine Installation von Plugins.", + "install_managed_id": "Ihr Administrator stellt bereits ein Plugin mit diesem Namen bereit. Es kann daher nicht ersetzt werden.", + "install_invalid": "Diese Datei ist kein installierbares Plugin: {details}", + "toast_installed": "Plugin installiert. Schalten Sie es unten ein.", + "toast_installed_pending": "Plugin installiert und Ihrem Administrator zur Freigabe vorgelegt.", + "toast_enabled": "{name} ist eingeschaltet", + "toast_disabled": "{name} ist ausgeschaltet", + "toast_removed": "{name} wurde entfernt", + "toast_forced": "Ihr Administrator lässt {name} eingeschaltet", + "toast_approval_requested": "{name} wartet auf die Freigabe durch Ihren Administrator", + "empty_title": "Noch keine Plugins", + "empty_admin": "Ihr Administrator hat keine Plugins hinzugefügt.", + "empty_uploads": "Installieren Sie zum Start ein Plugin aus einer ZIP-Datei.", + "empty_demo": "Plugins brauchen ein echtes Konto, daher gibt es in der Demo keine.", + "syncing": "Plugins werden mit dem Server abgeglichen…", + "status_installed": "Aus", + "status_enabled": "Wird gestartet", + "status_running": "An", + "status_disabled": "Aus", + "status_error": "Problem", + "badge_forced": "Erforderlich", + "badge_managed": "Von Ihrem Administrator", + "badge_yours": "Von Ihnen installiert", + "badge_awaiting": "Wartet auf Freigabe", + "toggle_label": "{name} ein- oder ausschalten", + "forced_note": "Ihr Administrator schreibt dieses Plugin vor. Es kann daher nicht ausgeschaltet werden.", + "permissions": "Was es darf", + "permissions_managed": "Was es darf (von Ihrem Administrator freigegeben)", + "settings": "Einstellungen", + "remove": "Entfernen", + "remove_title": "{name} entfernen?", + "remove_message": "Das Plugin und alles, was es in diesem Browser gespeichert hat, wird gelöscht. Sie können es später erneut installieren.", + "remove_confirm": "Entfernen", + "error_user_plugins_off": "Ihr Administrator hat von Benutzern installierte Plugins ausgeschaltet. Dieses Plugin läuft daher nicht.", + "error_approval_unavailable": "Dieses Plugin wurde installiert, bevor eine Freigabe nötig war. Entfernen Sie es und installieren Sie es erneut, um es zur Freigabe vorzulegen.", + "error_approval_denied": "Ihr Administrator hat dieses Plugin nicht freigegeben.", + "error_approval_pending": "Wartet darauf, dass Ihr Administrator dieses Plugin freigibt." + }, + "consent": { + "title": "{name} Folgendes erlauben?", + "intro": "Das Plugin bittet um die folgenden Berechtigungen. Es erhält sie nur, wenn Sie zustimmen.", + "high_risk_note": "Markierte Punkte geben dem Plugin Zugriff auf den Inhalt Ihrer E-Mails oder erlauben ihm, Daten anderswohin zu senden. Erlauben Sie sie nur Plugins, denen Sie vertrauen.", + "revoke_hint": "Sie können das Plugin jederzeit in den Einstellungen ausschalten oder entfernen.", + "deny": "Nicht erlauben", + "allow": "Erlauben" + }, + "permissions": { + "none": "Es braucht keine besonderen Berechtigungen.", + "high_risk": "Mit Vorsicht", + "email_read": { + "title": "Ihre E-Mails lesen", + "body": "Absender, Empfänger, Betreff und den vollständigen Text der Nachrichten sehen, die Sie öffnen, sowie der Nachrichtenliste." + }, + "email_write": { + "title": "Labels und Markierungen Ihrer E-Mails ändern", + "body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen." + }, + "email_send": { + "title": "E-Mails vor dem Senden ändern", + "body": "Empfänger, Betreff, Text und Anhänge der Nachrichten lesen und ändern, die Sie schreiben, und das Senden einer Nachricht verhindern." + }, + "crypto_full": { + "title": "Vollzugriff auf diese App", + "body": "Läuft mit denselben Rechten wie das Webmail selbst. Es kann alle Ihre E-Mails und gespeicherten Schlüssel lesen und in Ihrem Namen handeln. Erlauben Sie das nur Plugins, denen Sie voll vertrauen." + }, + "email_raw_send": { + "title": "Selbst erstellte Nachrichten senden", + "body": "Vollständige Nachrichten, zum Beispiel signierte oder verschlüsselte, von Ihrem Konto senden." + }, + "email_blob_read": { + "title": "Rohinhalt von Nachrichten lesen", + "body": "Die Originaldaten Ihrer Nachrichten und Anhänge lesen, auch jeder Datei, die Sie vor dem Senden anhängen." + }, + "email_blob_write": { + "title": "Anhänge vor dem Senden ersetzen", + "body": "Dateien auf Ihren Mailserver hochladen und eine angehängte Datei vor dem Senden durch eine andere ersetzen." + }, + "email_render_takeover": { + "title": "Darstellung von Nachrichten ändern", + "body": "Den Text geöffneter Nachrichten und der Nachrichtenliste ersetzen, zum Beispiel um entschlüsselte Inhalte anzuzeigen." + }, + "calendar_read": { + "title": "Ihren Kalender lesen", + "body": "Ihre Kalender und deren Termine sehen." + }, + "calendar_write": { + "title": "Ihren Kalender ändern", + "body": "Termine erstellen, ändern und löschen." + }, + "contacts_read": { + "title": "Ihre Kontakte lesen", + "body": "Ihre Adressbücher durchsuchen und lesen." + }, + "contacts_write": { + "title": "Ihre Kontakte ändern", + "body": "Kontakte und Adressbücher erstellen, ändern und löschen." + }, + "files_read": { + "title": "Ihre Dateien lesen", + "body": "Die in Ihrem Konto gespeicherten Dateien durchsuchen und öffnen." + }, + "files_write": { + "title": "Ihre Dateien ändern", + "body": "Dateien in Ihrem Konto hochladen, umbenennen, verschieben und löschen." + }, + "identity_read": { + "title": "Ihre Absenderadressen sehen", + "body": "Die Adressen, von denen Sie senden, und deren Signaturen sehen." + }, + "identity_write": { + "title": "Ihre Absenderadressen ändern", + "body": "Absenderadressen und Signaturen erstellen, ändern und löschen." + }, + "filters_read": { + "title": "Ihre E-Mail-Filter lesen", + "body": "Die Regeln sehen, die Ihre eingehenden E-Mails sortieren." + }, + "filters_write": { + "title": "Ihre E-Mail-Filter ändern", + "body": "Regeln zu Ihren serverseitigen Filtern hinzufügen. Solche Regeln können eingehende E-Mails verschieben, weiterleiten oder löschen." + }, + "tasks_read": { + "title": "Ihre Aufgaben lesen", + "body": "Ihre Aufgabenlisten und Aufgaben sehen." + }, + "tasks_write": { + "title": "Ihre Aufgaben ändern", + "body": "Aufgaben erstellen, ändern, erledigen und löschen." + }, + "templates_read": { + "title": "Ihre Vorlagen lesen", + "body": "Ihre gespeicherten Nachrichtenvorlagen sehen." + }, + "templates_write": { + "title": "Ihre Vorlagen ändern", + "body": "Nachrichtenvorlagen erstellen, ändern und löschen." + }, + "smime_read": { + "title": "Ihren S/MIME-Status sehen", + "body": "Sehen, ob Sie S/MIME-Schlüssel und -Zertifikate haben, ohne Zugriff auf die Schlüssel." + }, + "vacation_read": { + "title": "Ihre Abwesenheitsnotiz sehen", + "body": "Sehen, ob Ihre automatische Antwort eingeschaltet ist und was sie enthält." + }, + "vacation_write": { + "title": "Ihre Abwesenheitsnotiz ändern", + "body": "Ihre automatische Antwort ein- oder ausschalten und ihren Text und Zeitraum ändern." + }, + "settings_read": { + "title": "Ihre Einstellungen lesen", + "body": "Ihre Einstellungen und Label-Definitionen sehen." + }, + "settings_write": { + "title": "Ihre Einstellungen ändern", + "body": "Labels hinzufügen und neu anordnen und Ihre Einstellungen ändern." + }, + "security_read": { + "title": "Ihre Kontosicherheit sehen", + "body": "Sehen, ob Zwei-Faktor-Anmeldung und Verschlüsselung eingeschaltet sind, ohne Zugriff auf Geheimnisse." + }, + "auth_observe": { + "title": "Erfahren, wann Sie sich anmelden", + "body": "Benachrichtigt werden, wenn Sie sich anmelden, das Konto wechseln oder Ihre Sitzung erneuert wird." + }, + "auth_emit": { + "title": "Sie abmelden", + "body": "Sie vom Webmail abmelden." + }, + "account_read": { + "title": "Ihre Konten sehen", + "body": "Die von Ihnen hinzugefügten Konten mit ihren Adressen und Servern sehen." + }, + "http_post": { + "title": "Den Webmail-Server für Sie nutzen", + "body": "Diese Server-Endpunkte mit Ihrer Anmeldung aufrufen:" + }, + "http_fetch": { + "title": "Mit anderen Websites verbinden", + "body": "Anfragen an diese Websites senden. Alles, was das Plugin lesen kann, kann dorthin gesendet werden:" + }, + "admin_config": { + "title": "Seine Einstellungen von Ihrem Administrator lesen", + "body": "Die Einstellungen lesen, die Ihr Administrator für dieses Plugin gespeichert hat." + }, + "ui_observe": { + "title": "Verfolgen, wo Sie sich in der App befinden", + "body": "Wissen, welche Seite geöffnet ist, und auf angeklickte Links reagieren." + }, + "ui_toolbar": { + "title": "Schaltflächen zur Nachrichten-Symbolleiste hinzufügen", + "body": "Eigene Schaltflächen über einer geöffneten Nachricht anzeigen." + }, + "ui_app_top_banner": { + "title": "Oben ein Banner anzeigen", + "body": "Einen Hinweis über die gesamte Breite oben in der App anzeigen." + }, + "ui_email_banner": { + "title": "Hinweise bei Nachrichten anzeigen", + "body": "Einen Hinweis über dem Text einer geöffneten Nachricht anzeigen." + }, + "ui_email_footer": { + "title": "Inhalte unter Nachrichten hinzufügen", + "body": "Eigene Inhalte unter einer geöffneten Nachricht anzeigen." + }, + "ui_contact_cryptokeys": { + "title": "Verschlüsselungsschlüssel von Kontakten anzeigen", + "body": "Die Verschlüsselungsschlüssel eines Kontakts anzeigen und verwalten." + }, + "ui_email_details": { + "title": "Bereiche zu Nachrichtendetails hinzufügen", + "body": "Einen eigenen Bereich neben oder unter einer geöffneten Nachricht anzeigen." + }, + "ui_download_file": { + "title": "Dateien auf Ihrem Gerät speichern", + "body": "Selbst erstellte Dateien zum Herunterladen anbieten." + }, + "ui_message_list_tabs": { + "title": "Tabs zu Ihrem Posteingang hinzufügen", + "body": "Ihren Posteingang in Tabs wie Updates oder Werbung aufteilen." + }, + "ui_composer_toolbar": { + "title": "Schaltflächen zum Editor hinzufügen", + "body": "Eigene Schaltflächen anzeigen, während Sie eine Nachricht schreiben." + }, + "ui_composer_sidebar": { + "title": "Einen Bereich zum Editor hinzufügen", + "body": "Einen eigenen Bereich neben der Nachricht anzeigen, die Sie schreiben." + }, + "ui_sidebar_widget": { + "title": "Ein Feld zur Seitenleiste hinzufügen", + "body": "Eigene Inhalte in der E-Mail-Seitenleiste anzeigen." + }, + "ui_settings_section": { + "title": "Eine Einstellungsseite hinzufügen", + "body": "Eine eigene Seite in den Einstellungen anzeigen." + }, + "ui_context_menu": { + "title": "Zum Nachrichtenmenü hinzufügen", + "body": "Einträge zu dem Menü hinzufügen, das sich per Rechtsklick auf eine Nachricht öffnet." + }, + "ui_navigation_rail": { + "title": "Zur Navigationsleiste hinzufügen", + "body": "Eine eigene Schaltfläche in der Navigationsleiste anzeigen." + }, + "ui_keyboard": { + "title": "Tastaturkürzel hinzufügen", + "body": "Auf selbst festgelegte Tastaturkürzel reagieren." + }, + "ui_calendar_action": { + "title": "Schaltflächen zu Terminen hinzufügen", + "body": "Eigene Schaltflächen anzeigen, wenn Sie einen Termin erstellen oder bearbeiten." + }, + "ui_admin_page": { + "title": "Eine Admin-Seite hinzufügen", + "body": "Eine eigene Seite in den Admin-Einstellungen anzeigen." + }, + "app_lifecycle": { + "title": "Erfahren, wann die App startet und beendet wird", + "body": "Benachrichtigt werden, wenn die App startet, offline geht oder Sie sich abmelden." + } } }, "global_search": { diff --git a/locales/en/common.json b/locales/en/common.json index 21bd7d292..f81c847c7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Authorization failed: {error}", "missing_params": "Missing authorization code or state.", "delivery_failed": "The authorization result could not be delivered to the app tab. Please close this window and try again." + }, + "settings": { + "title": "Plugins", + "description": "Plugins your administrator added. Turn them on or off for your account.", + "description_uploads": "Plugins add features to the webmail. Turn them on or off for your account, or install your own.", + "description_demo": "Plugins are not available in the demo.", + "install_button": "Install plugin", + "install_hint": "Choose a plugin ZIP file.", + "install_hint_approval": "Choose a plugin ZIP file. An administrator has to approve it before you can turn it on.", + "install_uploads_off": "Your administrator does not allow installing plugins.", + "install_managed_id": "Your administrator already provides a plugin with this name, so it can't be replaced.", + "install_invalid": "This file is not a plugin that can be installed: {details}", + "toast_installed": "Plugin installed. Turn it on below.", + "toast_installed_pending": "Plugin installed and sent to your administrator for approval.", + "toast_enabled": "{name} is on", + "toast_disabled": "{name} is off", + "toast_removed": "{name} was removed", + "toast_forced": "Your administrator keeps {name} turned on", + "toast_approval_requested": "{name} is waiting for approval by your administrator", + "empty_title": "No plugins yet", + "empty_admin": "Your administrator has not added any plugins.", + "empty_uploads": "Install a plugin from a ZIP file to get started.", + "empty_demo": "Plugins need a real account, so the demo has none.", + "syncing": "Checking plugins with the server…", + "status_installed": "Off", + "status_enabled": "Starting", + "status_running": "On", + "status_disabled": "Off", + "status_error": "Problem", + "badge_forced": "Required", + "badge_managed": "From your administrator", + "badge_yours": "Installed by you", + "badge_awaiting": "Awaiting approval", + "toggle_label": "Turn {name} on or off", + "forced_note": "Your administrator requires this plugin, so it can't be turned off.", + "permissions": "What it can do", + "permissions_managed": "What it can do (approved by your administrator)", + "settings": "Settings", + "remove": "Remove", + "remove_title": "Remove {name}?", + "remove_message": "The plugin and everything it stored in this browser are deleted. You can install it again later.", + "remove_confirm": "Remove", + "error_user_plugins_off": "Your administrator turned off plugins installed by users, so this plugin does not run.", + "error_approval_unavailable": "This plugin was installed before approvals were required. Remove it and install it again to send it for approval.", + "error_approval_denied": "Your administrator did not approve this plugin.", + "error_approval_pending": "Waiting for your administrator to approve this plugin." + }, + "consent": { + "title": "Allow {name} to do this?", + "intro": "The plugin asks for the permissions below. It only gets them if you allow it.", + "high_risk_note": "Marked items give the plugin access to the content of your mail or let it send data elsewhere. Only allow them for plugins you trust.", + "revoke_hint": "You can turn the plugin off or remove it in Settings at any time.", + "deny": "Don't allow", + "allow": "Allow" + }, + "permissions": { + "none": "It needs no special permissions.", + "high_risk": "Needs care", + "email_read": { + "title": "Read your email", + "body": "See senders, recipients, subjects and the full text of messages you open and of the message list." + }, + "email_write": { + "title": "Change tags and flags on your email", + "body": "Add or remove tags, flags and other keywords on your messages." + }, + "email_send": { + "title": "Change mail before it is sent", + "body": "Read and change the recipients, subject, text and attachments of messages you write, and stop a message from being sent." + }, + "crypto_full": { + "title": "Full access to this app", + "body": "Runs with the same rights as the webmail itself. It can read all your mail and stored keys and act for you. Only allow this for plugins you fully trust." + }, + "email_raw_send": { + "title": "Send messages it built itself", + "body": "Send complete messages, for example signed or encrypted ones, from your account." + }, + "email_blob_read": { + "title": "Read the raw content of messages", + "body": "Read the original data of your messages and attachments, and of every file you attach before it is sent." + }, + "email_blob_write": { + "title": "Replace attachments before sending", + "body": "Upload files to your mail server and replace a file you attached with another one before the message is sent." + }, + "email_render_takeover": { + "title": "Change how messages are shown", + "body": "Replace the text of messages you open and of the message list, for example to show decrypted content." + }, + "calendar_read": { + "title": "Read your calendar", + "body": "See your calendars and their events." + }, + "calendar_write": { + "title": "Change your calendar", + "body": "Create, change and delete events." + }, + "contacts_read": { + "title": "Read your contacts", + "body": "Search and read your address books." + }, + "contacts_write": { + "title": "Change your contacts", + "body": "Create, change and delete contacts and address books." + }, + "files_read": { + "title": "Read your files", + "body": "Browse and open the files stored in your account." + }, + "files_write": { + "title": "Change your files", + "body": "Upload, rename, move and delete files in your account." + }, + "identity_read": { + "title": "See your sender addresses", + "body": "See the addresses you send from and their signatures." + }, + "identity_write": { + "title": "Change your sender addresses", + "body": "Create, change and delete sender addresses and signatures." + }, + "filters_read": { + "title": "Read your mail filters", + "body": "See the rules that sort your incoming mail." + }, + "filters_write": { + "title": "Change your mail filters", + "body": "Add rules to your server-side filters. Such rules can move, forward or delete incoming mail." + }, + "tasks_read": { + "title": "Read your tasks", + "body": "See your task lists and tasks." + }, + "tasks_write": { + "title": "Change your tasks", + "body": "Create, change, complete and delete tasks." + }, + "templates_read": { + "title": "Read your templates", + "body": "See your saved message templates." + }, + "templates_write": { + "title": "Change your templates", + "body": "Create, change and delete message templates." + }, + "smime_read": { + "title": "See your S/MIME status", + "body": "See whether you have S/MIME keys and certificates, without access to the keys." + }, + "vacation_read": { + "title": "See your out-of-office reply", + "body": "See whether your automatic reply is on and what it says." + }, + "vacation_write": { + "title": "Change your out-of-office reply", + "body": "Turn your automatic reply on or off and change its text and dates." + }, + "settings_read": { + "title": "Read your settings", + "body": "See your preferences and tag definitions." + }, + "settings_write": { + "title": "Change your settings", + "body": "Add and reorder tags and change your preferences." + }, + "security_read": { + "title": "See your account security", + "body": "See whether two-factor sign-in and encryption are on, without access to secrets." + }, + "auth_observe": { + "title": "Know when you sign in", + "body": "Be told when you sign in, switch accounts or your session is renewed." + }, + "auth_emit": { + "title": "Sign you out", + "body": "Sign you out of the webmail." + }, + "account_read": { + "title": "See your accounts", + "body": "See the accounts you added, with their addresses and servers." + }, + "http_post": { + "title": "Use the webmail server for you", + "body": "Call these server endpoints with your login:" + }, + "http_fetch": { + "title": "Connect to other websites", + "body": "Send requests to these sites. Anything the plugin can read may be sent there:" + }, + "admin_config": { + "title": "Read its settings from your administrator", + "body": "Read the settings your administrator saved for this plugin." + }, + "ui_observe": { + "title": "Follow where you are in the app", + "body": "Know which page is open and react to links you click." + }, + "ui_toolbar": { + "title": "Add buttons to the message toolbar", + "body": "Show its own buttons above an open message." + }, + "ui_app_top_banner": { + "title": "Show a banner at the top", + "body": "Show a notice across the top of the app." + }, + "ui_email_banner": { + "title": "Show notices on messages", + "body": "Show a notice above the text of an open message." + }, + "ui_email_footer": { + "title": "Add content below messages", + "body": "Show its own content under an open message." + }, + "ui_contact_cryptokeys": { + "title": "Show encryption keys of contacts", + "body": "Show and manage the encryption keys on a contact." + }, + "ui_email_details": { + "title": "Add panels to message details", + "body": "Show its own panel next to or below an open message." + }, + "ui_download_file": { + "title": "Save files to your device", + "body": "Offer files it created for download." + }, + "ui_message_list_tabs": { + "title": "Add tabs to your inbox", + "body": "Sort your inbox into tabs such as Updates or Promotions." + }, + "ui_composer_toolbar": { + "title": "Add buttons to the composer", + "body": "Show its own buttons while you write a message." + }, + "ui_composer_sidebar": { + "title": "Add a panel to the composer", + "body": "Show its own panel next to the message you write." + }, + "ui_sidebar_widget": { + "title": "Add a box to the sidebar", + "body": "Show its own content in the mail sidebar." + }, + "ui_settings_section": { + "title": "Add a settings page", + "body": "Show its own page in Settings." + }, + "ui_context_menu": { + "title": "Add to the message menu", + "body": "Add entries to the menu that opens when you right-click a message." + }, + "ui_navigation_rail": { + "title": "Add to the navigation bar", + "body": "Show its own button in the navigation bar." + }, + "ui_keyboard": { + "title": "Add keyboard shortcuts", + "body": "React to keyboard shortcuts it defines." + }, + "ui_calendar_action": { + "title": "Add buttons to events", + "body": "Show its own buttons when you create or edit an event." + }, + "ui_admin_page": { + "title": "Add an admin page", + "body": "Show its own page in the admin settings." + }, + "app_lifecycle": { + "title": "Know when the app starts and stops", + "body": "Be told when the app starts, goes offline or you sign out." + } } }, "global_search": { diff --git a/locales/es/common.json b/locales/es/common.json index b9c0de9da..fb82b54e5 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Error de autorización: {error}", "missing_params": "Falta el código de autorización o el estado.", "delivery_failed": "No se pudo entregar el resultado de la autorización a la pestaña de la aplicación. Cierre esta ventana e inténtelo de nuevo." + }, + "settings": { + "title": "Complementos", + "description": "Complementos que añadió tu administrador. Actívalos o desactívalos para tu cuenta.", + "description_uploads": "Los complementos añaden funciones al webmail. Actívalos o desactívalos para tu cuenta, o instala los tuyos.", + "description_demo": "Los complementos no están disponibles en la demo.", + "install_button": "Instalar complemento", + "install_hint": "Elige el archivo ZIP de un complemento.", + "install_hint_approval": "Elige el archivo ZIP de un complemento. Un administrador tiene que aprobarlo antes de que puedas activarlo.", + "install_uploads_off": "Tu administrador no permite instalar complementos.", + "install_managed_id": "Tu administrador ya ofrece un complemento con este nombre, así que no se puede reemplazar.", + "install_invalid": "Este archivo no es un complemento que se pueda instalar: {details}", + "toast_installed": "Complemento instalado. Actívalo abajo.", + "toast_installed_pending": "Complemento instalado y enviado a tu administrador para su aprobación.", + "toast_enabled": "{name} está activado", + "toast_disabled": "{name} está desactivado", + "toast_removed": "Se eliminó {name}", + "toast_forced": "Tu administrador mantiene {name} activado", + "toast_approval_requested": "{name} está pendiente de la aprobación de tu administrador", + "empty_title": "Aún no hay complementos", + "empty_admin": "Tu administrador no ha añadido ningún complemento.", + "empty_uploads": "Instala un complemento desde un archivo ZIP para empezar.", + "empty_demo": "Los complementos necesitan una cuenta real, así que la demo no tiene ninguno.", + "syncing": "Comprobando los complementos con el servidor…", + "status_installed": "Desactivado", + "status_enabled": "Iniciando", + "status_running": "Activado", + "status_disabled": "Desactivado", + "status_error": "Problema", + "badge_forced": "Obligatorio", + "badge_managed": "De tu administrador", + "badge_yours": "Instalado por ti", + "badge_awaiting": "Pendiente de aprobación", + "toggle_label": "Activar o desactivar {name}", + "forced_note": "Tu administrador exige este complemento, así que no se puede desactivar.", + "permissions": "Qué puede hacer", + "permissions_managed": "Qué puede hacer (aprobado por tu administrador)", + "settings": "Configuración", + "remove": "Eliminar", + "remove_title": "¿Eliminar {name}?", + "remove_message": "Se eliminarán el complemento y todo lo que guardó en este navegador. Puedes volver a instalarlo más tarde.", + "remove_confirm": "Eliminar", + "error_user_plugins_off": "Tu administrador desactivó los complementos instalados por usuarios, así que este complemento no se ejecuta.", + "error_approval_unavailable": "Este complemento se instaló antes de que se exigiera aprobación. Elimínalo e instálalo de nuevo para enviarlo a aprobación.", + "error_approval_denied": "Tu administrador no aprobó este complemento.", + "error_approval_pending": "Esperando a que tu administrador apruebe este complemento." + }, + "consent": { + "title": "¿Permitir que {name} haga esto?", + "intro": "El complemento pide los permisos de abajo. Solo los obtiene si se los concedes.", + "high_risk_note": "Los elementos marcados dan al complemento acceso al contenido de tu correo o le permiten enviar datos a otros sitios. Permítelos solo en complementos de confianza.", + "revoke_hint": "Puedes desactivar o eliminar el complemento en Configuración en cualquier momento.", + "deny": "No permitir", + "allow": "Permitir" + }, + "permissions": { + "none": "No necesita permisos especiales.", + "high_risk": "Con cuidado", + "email_read": { + "title": "Leer tu correo", + "body": "Ver remitentes, destinatarios, asuntos y el texto completo de los mensajes que abres y de la lista de mensajes." + }, + "email_write": { + "title": "Cambiar etiquetas y marcas de tu correo", + "body": "Añadir o quitar etiquetas, marcas y otras palabras clave de tus mensajes." + }, + "email_send": { + "title": "Cambiar el correo antes de enviarlo", + "body": "Leer y cambiar los destinatarios, el asunto, el texto y los adjuntos de los mensajes que escribes, e impedir que se envíe un mensaje." + }, + "crypto_full": { + "title": "Acceso total a esta aplicación", + "body": "Se ejecuta con los mismos derechos que el propio webmail. Puede leer todo tu correo y tus claves guardadas y actuar en tu nombre. Permítelo solo en complementos en los que confíes plenamente." + }, + "email_raw_send": { + "title": "Enviar mensajes que creó él mismo", + "body": "Enviar mensajes completos, por ejemplo firmados o cifrados, desde tu cuenta." + }, + "email_blob_read": { + "title": "Leer el contenido sin procesar de los mensajes", + "body": "Leer los datos originales de tus mensajes y adjuntos, y de cada archivo que adjuntas antes de enviarlo." + }, + "email_blob_write": { + "title": "Reemplazar adjuntos antes de enviar", + "body": "Subir archivos a tu servidor de correo y reemplazar un archivo adjunto por otro antes de enviar el mensaje." + }, + "email_render_takeover": { + "title": "Cambiar cómo se muestran los mensajes", + "body": "Reemplazar el texto de los mensajes que abres y de la lista de mensajes, por ejemplo para mostrar contenido descifrado." + }, + "calendar_read": { + "title": "Leer tu calendario", + "body": "Ver tus calendarios y sus eventos." + }, + "calendar_write": { + "title": "Cambiar tu calendario", + "body": "Crear, cambiar y eliminar eventos." + }, + "contacts_read": { + "title": "Leer tus contactos", + "body": "Buscar y leer en tus libretas de direcciones." + }, + "contacts_write": { + "title": "Cambiar tus contactos", + "body": "Crear, cambiar y eliminar contactos y libretas de direcciones." + }, + "files_read": { + "title": "Leer tus archivos", + "body": "Explorar y abrir los archivos guardados en tu cuenta." + }, + "files_write": { + "title": "Cambiar tus archivos", + "body": "Subir, renombrar, mover y eliminar archivos de tu cuenta." + }, + "identity_read": { + "title": "Ver tus direcciones de remitente", + "body": "Ver las direcciones desde las que envías y sus firmas." + }, + "identity_write": { + "title": "Cambiar tus direcciones de remitente", + "body": "Crear, cambiar y eliminar direcciones de remitente y firmas." + }, + "filters_read": { + "title": "Leer tus filtros de correo", + "body": "Ver las reglas que ordenan tu correo entrante." + }, + "filters_write": { + "title": "Cambiar tus filtros de correo", + "body": "Añadir reglas a tus filtros del servidor. Esas reglas pueden mover, reenviar o eliminar el correo entrante." + }, + "tasks_read": { + "title": "Leer tus tareas", + "body": "Ver tus listas de tareas y tus tareas." + }, + "tasks_write": { + "title": "Cambiar tus tareas", + "body": "Crear, cambiar, completar y eliminar tareas." + }, + "templates_read": { + "title": "Leer tus plantillas", + "body": "Ver tus plantillas de mensaje guardadas." + }, + "templates_write": { + "title": "Cambiar tus plantillas", + "body": "Crear, cambiar y eliminar plantillas de mensaje." + }, + "smime_read": { + "title": "Ver tu estado de S/MIME", + "body": "Ver si tienes claves y certificados S/MIME, sin acceso a las claves." + }, + "vacation_read": { + "title": "Ver tu respuesta de ausencia", + "body": "Ver si tu respuesta automática está activada y qué dice." + }, + "vacation_write": { + "title": "Cambiar tu respuesta de ausencia", + "body": "Activar o desactivar tu respuesta automática y cambiar su texto y sus fechas." + }, + "settings_read": { + "title": "Leer tu configuración", + "body": "Ver tus preferencias y las definiciones de tus etiquetas." + }, + "settings_write": { + "title": "Cambiar tu configuración", + "body": "Añadir y reordenar etiquetas y cambiar tus preferencias." + }, + "security_read": { + "title": "Ver la seguridad de tu cuenta", + "body": "Ver si el inicio de sesión con dos factores y el cifrado están activados, sin acceso a secretos." + }, + "auth_observe": { + "title": "Saber cuándo inicias sesión", + "body": "Recibir un aviso cuando inicias sesión, cambias de cuenta o se renueva tu sesión." + }, + "auth_emit": { + "title": "Cerrar tu sesión", + "body": "Cerrar tu sesión en el webmail." + }, + "account_read": { + "title": "Ver tus cuentas", + "body": "Ver las cuentas que añadiste, con sus direcciones y servidores." + }, + "http_post": { + "title": "Usar el servidor del webmail por ti", + "body": "Llamar a estos endpoints del servidor con tu inicio de sesión:" + }, + "http_fetch": { + "title": "Conectarse a otros sitios web", + "body": "Enviar solicitudes a estos sitios. Todo lo que el complemento pueda leer se puede enviar allí:" + }, + "admin_config": { + "title": "Leer su configuración de tu administrador", + "body": "Leer la configuración que tu administrador guardó para este complemento." + }, + "ui_observe": { + "title": "Seguir dónde estás en la aplicación", + "body": "Saber qué página está abierta y reaccionar a los enlaces en los que haces clic." + }, + "ui_toolbar": { + "title": "Añadir botones a la barra de herramientas del mensaje", + "body": "Mostrar sus propios botones encima de un mensaje abierto." + }, + "ui_app_top_banner": { + "title": "Mostrar un banner arriba", + "body": "Mostrar un aviso a lo ancho de la parte superior de la aplicación." + }, + "ui_email_banner": { + "title": "Mostrar avisos en los mensajes", + "body": "Mostrar un aviso encima del texto de un mensaje abierto." + }, + "ui_email_footer": { + "title": "Añadir contenido debajo de los mensajes", + "body": "Mostrar su propio contenido debajo de un mensaje abierto." + }, + "ui_contact_cryptokeys": { + "title": "Mostrar las claves de cifrado de los contactos", + "body": "Mostrar y gestionar las claves de cifrado de un contacto." + }, + "ui_email_details": { + "title": "Añadir paneles a los detalles del mensaje", + "body": "Mostrar su propio panel junto a un mensaje abierto o debajo de él." + }, + "ui_download_file": { + "title": "Guardar archivos en tu dispositivo", + "body": "Ofrecer para descargar archivos que creó." + }, + "ui_message_list_tabs": { + "title": "Añadir pestañas a tu bandeja de entrada", + "body": "Ordenar tu bandeja de entrada en pestañas como Novedades o Promociones." + }, + "ui_composer_toolbar": { + "title": "Añadir botones al editor", + "body": "Mostrar sus propios botones mientras escribes un mensaje." + }, + "ui_composer_sidebar": { + "title": "Añadir un panel al editor", + "body": "Mostrar su propio panel junto al mensaje que escribes." + }, + "ui_sidebar_widget": { + "title": "Añadir un recuadro a la barra lateral", + "body": "Mostrar su propio contenido en la barra lateral del correo." + }, + "ui_settings_section": { + "title": "Añadir una página de configuración", + "body": "Mostrar su propia página en Configuración." + }, + "ui_context_menu": { + "title": "Añadir al menú del mensaje", + "body": "Añadir entradas al menú que se abre al hacer clic derecho en un mensaje." + }, + "ui_navigation_rail": { + "title": "Añadir a la barra de navegación", + "body": "Mostrar su propio botón en la barra de navegación." + }, + "ui_keyboard": { + "title": "Añadir atajos de teclado", + "body": "Reaccionar a los atajos de teclado que define." + }, + "ui_calendar_action": { + "title": "Añadir botones a los eventos", + "body": "Mostrar sus propios botones cuando creas o editas un evento." + }, + "ui_admin_page": { + "title": "Añadir una página de administración", + "body": "Mostrar su propia página en la configuración de administración." + }, + "app_lifecycle": { + "title": "Saber cuándo se inicia y se cierra la aplicación", + "body": "Recibir un aviso cuando la aplicación se inicia, se queda sin conexión o cierras sesión." + } } }, "global_search": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 0e6a11a28..4ac2702fe 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "احراز هویت ناموفق بود: {error}", "missing_params": "کد احراز هویت یا حالت وجود ندارد.", "delivery_failed": "نتیجه احراز هویت به برگه برنامه ارسال نشد. لطفاً این پنجره را ببندید و دوباره تلاش کنید." + }, + "settings": { + "title": "افزونه‌ها", + "description": "افزونه‌هایی که مدیر سیستم اضافه کرده است. آن‌ها را برای حساب خود روشن یا خاموش کنید.", + "description_uploads": "افزونه‌ها قابلیت‌هایی به ایمیل تحت وب اضافه می‌کنند. آن‌ها را برای حساب خود روشن یا خاموش کنید، یا افزونه خودتان را نصب کنید.", + "description_demo": "افزونه‌ها در نسخه آزمایشی در دسترس نیستند.", + "install_button": "نصب افزونه", + "install_hint": "یک فایل ZIP افزونه انتخاب کنید.", + "install_hint_approval": "یک فایل ZIP افزونه انتخاب کنید. پیش از آنکه بتوانید آن را روشن کنید، مدیر سیستم باید آن را تأیید کند.", + "install_uploads_off": "مدیر سیستم اجازه نصب افزونه را نمی‌دهد.", + "install_managed_id": "مدیر سیستم از قبل افزونه‌ای با این نام ارائه می‌دهد، بنابراین نمی‌توان آن را جایگزین کرد.", + "install_invalid": "این فایل افزونه‌ای قابل نصب نیست: {details}", + "toast_installed": "افزونه نصب شد. آن را در پایین روشن کنید.", + "toast_installed_pending": "افزونه نصب شد و برای تأیید به مدیر سیستم ارسال شد.", + "toast_enabled": "{name} روشن است", + "toast_disabled": "{name} خاموش است", + "toast_removed": "{name} حذف شد", + "toast_forced": "مدیر سیستم {name} را روشن نگه می‌دارد", + "toast_approval_requested": "{name} در انتظار تأیید مدیر سیستم است", + "empty_title": "هنوز افزونه‌ای وجود ندارد", + "empty_admin": "مدیر سیستم هیچ افزونه‌ای اضافه نکرده است.", + "empty_uploads": "برای شروع، یک افزونه را از فایل ZIP نصب کنید.", + "empty_demo": "افزونه‌ها به یک حساب واقعی نیاز دارند، بنابراین نسخه آزمایشی هیچ افزونه‌ای ندارد.", + "syncing": "در حال بررسی افزونه‌ها با سرور…", + "status_installed": "خاموش", + "status_enabled": "در حال راه‌اندازی", + "status_running": "روشن", + "status_disabled": "خاموش", + "status_error": "مشکل", + "badge_forced": "الزامی", + "badge_managed": "از طرف مدیر سیستم", + "badge_yours": "نصب‌شده توسط شما", + "badge_awaiting": "در انتظار تأیید", + "toggle_label": "روشن یا خاموش کردن {name}", + "forced_note": "مدیر سیستم این افزونه را الزامی کرده است، بنابراین نمی‌توان آن را خاموش کرد.", + "permissions": "چه کارهایی می‌تواند انجام دهد", + "permissions_managed": "چه کارهایی می‌تواند انجام دهد (تأییدشده توسط مدیر سیستم)", + "settings": "تنظیمات", + "remove": "حذف", + "remove_title": "{name} حذف شود؟", + "remove_message": "افزونه و هر چیزی که در این مرورگر ذخیره کرده است پاک می‌شود. بعداً می‌توانید دوباره آن را نصب کنید.", + "remove_confirm": "حذف", + "error_user_plugins_off": "مدیر سیستم افزونه‌هایی را که کاربران نصب می‌کنند خاموش کرده است، بنابراین این افزونه اجرا نمی‌شود.", + "error_approval_unavailable": "این افزونه پیش از الزامی شدن تأیید نصب شده است. آن را حذف و دوباره نصب کنید تا برای تأیید ارسال شود.", + "error_approval_denied": "مدیر سیستم این افزونه را تأیید نکرد.", + "error_approval_pending": "در انتظار تأیید این افزونه توسط مدیر سیستم." + }, + "consent": { + "title": "به {name} اجازه این کار داده شود؟", + "intro": "این افزونه مجوزهای زیر را درخواست می‌کند. فقط در صورتی آن‌ها را دریافت می‌کند که شما اجازه دهید.", + "high_risk_note": "موارد علامت‌دار به افزونه اجازه دسترسی به محتوای ایمیل‌های شما یا ارسال داده به جای دیگر را می‌دهند. فقط برای افزونه‌هایی که به آن‌ها اعتماد دارید اجازه دهید.", + "revoke_hint": "هر زمان می‌توانید افزونه را در تنظیمات خاموش یا حذف کنید.", + "deny": "اجازه نده", + "allow": "اجازه بده" + }, + "permissions": { + "none": "به هیچ مجوز خاصی نیاز ندارد.", + "high_risk": "نیازمند احتیاط", + "email_read": { + "title": "خواندن ایمیل‌های شما", + "body": "دیدن فرستندگان، گیرندگان، موضوع‌ها و متن کامل پیام‌هایی که باز می‌کنید و فهرست پیام‌ها." + }, + "email_write": { + "title": "تغییر برچسب‌ها و پرچم‌های ایمیل‌های شما", + "body": "افزودن یا حذف برچسب‌ها، پرچم‌ها و کلیدواژه‌های دیگر روی پیام‌های شما." + }, + "email_send": { + "title": "تغییر ایمیل پیش از ارسال", + "body": "خواندن و تغییر گیرندگان، موضوع، متن و پیوست‌های پیام‌هایی که می‌نویسید، و جلوگیری از ارسال یک پیام." + }, + "crypto_full": { + "title": "دسترسی کامل به این برنامه", + "body": "با همان دسترسی‌های خود ایمیل تحت وب اجرا می‌شود. می‌تواند همه ایمیل‌ها و کلیدهای ذخیره‌شده شما را بخواند و از طرف شما عمل کند. فقط برای افزونه‌هایی که کاملاً به آن‌ها اعتماد دارید اجازه دهید." + }, + "email_raw_send": { + "title": "ارسال پیام‌هایی که خودش ساخته است", + "body": "ارسال پیام‌های کامل، مثلاً امضاشده یا رمزنگاری‌شده، از حساب شما." + }, + "email_blob_read": { + "title": "خواندن محتوای خام پیام‌ها", + "body": "خواندن داده‌های اصلی پیام‌ها و پیوست‌های شما، و هر فایلی که پیش از ارسال پیوست می‌کنید." + }, + "email_blob_write": { + "title": "جایگزینی پیوست‌ها پیش از ارسال", + "body": "بارگذاری فایل در سرور ایمیل شما و جایگزینی فایلی که پیوست کرده‌اید با فایلی دیگر، پیش از ارسال پیام." + }, + "email_render_takeover": { + "title": "تغییر نحوه نمایش پیام‌ها", + "body": "جایگزینی متن پیام‌هایی که باز می‌کنید و فهرست پیام‌ها، مثلاً برای نمایش محتوای رمزگشایی‌شده." + }, + "calendar_read": { + "title": "خواندن تقویم شما", + "body": "دیدن تقویم‌ها و رویدادهای شما." + }, + "calendar_write": { + "title": "تغییر تقویم شما", + "body": "ایجاد، تغییر و حذف رویدادها." + }, + "contacts_read": { + "title": "خواندن مخاطبان شما", + "body": "جست‌وجو و خواندن دفترچه‌های آدرس شما." + }, + "contacts_write": { + "title": "تغییر مخاطبان شما", + "body": "ایجاد، تغییر و حذف مخاطبان و دفترچه‌های آدرس." + }, + "files_read": { + "title": "خواندن فایل‌های شما", + "body": "مرور و باز کردن فایل‌های ذخیره‌شده در حساب شما." + }, + "files_write": { + "title": "تغییر فایل‌های شما", + "body": "بارگذاری، تغییر نام، جابه‌جایی و حذف فایل‌ها در حساب شما." + }, + "identity_read": { + "title": "دیدن آدرس‌های فرستنده شما", + "body": "دیدن آدرس‌هایی که از آن‌ها ایمیل می‌فرستید و امضاهای آن‌ها." + }, + "identity_write": { + "title": "تغییر آدرس‌های فرستنده شما", + "body": "ایجاد، تغییر و حذف آدرس‌های فرستنده و امضاها." + }, + "filters_read": { + "title": "خواندن فیلترهای ایمیل شما", + "body": "دیدن قوانینی که ایمیل‌های ورودی شما را مرتب می‌کنند." + }, + "filters_write": { + "title": "تغییر فیلترهای ایمیل شما", + "body": "افزودن قانون به فیلترهای سمت سرور. این قوانین می‌توانند ایمیل‌های ورودی را جابه‌جا، هدایت یا حذف کنند." + }, + "tasks_read": { + "title": "خواندن کارهای شما", + "body": "دیدن فهرست‌های کار و کارهای شما." + }, + "tasks_write": { + "title": "تغییر کارهای شما", + "body": "ایجاد، تغییر، تکمیل و حذف کارها." + }, + "templates_read": { + "title": "خواندن قالب‌های شما", + "body": "دیدن قالب‌های پیام ذخیره‌شده شما." + }, + "templates_write": { + "title": "تغییر قالب‌های شما", + "body": "ایجاد، تغییر و حذف قالب‌های پیام." + }, + "smime_read": { + "title": "دیدن وضعیت S/MIME شما", + "body": "دیدن اینکه آیا کلیدها و گواهی‌های S/MIME دارید، بدون دسترسی به کلیدها." + }, + "vacation_read": { + "title": "دیدن پاسخ خودکار شما", + "body": "دیدن اینکه آیا پاسخ خودکار شما روشن است و چه متنی دارد." + }, + "vacation_write": { + "title": "تغییر پاسخ خودکار شما", + "body": "روشن یا خاموش کردن پاسخ خودکار و تغییر متن و تاریخ‌های آن." + }, + "settings_read": { + "title": "خواندن تنظیمات شما", + "body": "دیدن ترجیحات و تعریف برچسب‌های شما." + }, + "settings_write": { + "title": "تغییر تنظیمات شما", + "body": "افزودن و مرتب کردن برچسب‌ها و تغییر ترجیحات شما." + }, + "security_read": { + "title": "دیدن امنیت حساب شما", + "body": "دیدن اینکه آیا احراز هویت دو مرحله‌ای و رمزنگاری روشن هستند، بدون دسترسی به اطلاعات محرمانه." + }, + "auth_observe": { + "title": "باخبر شدن از ورود شما", + "body": "باخبر شدن وقتی وارد می‌شوید، حساب را عوض می‌کنید یا نشست شما تمدید می‌شود." + }, + "auth_emit": { + "title": "خارج کردن شما از حساب", + "body": "خارج کردن شما از ایمیل تحت وب." + }, + "account_read": { + "title": "دیدن حساب‌های شما", + "body": "دیدن حساب‌هایی که اضافه کرده‌اید، همراه با آدرس‌ها و سرورهای آن‌ها." + }, + "http_post": { + "title": "استفاده از سرور ایمیل تحت وب از طرف شما", + "body": "فراخوانی این نقاط پایانی سرور با اطلاعات ورود شما:" + }, + "http_fetch": { + "title": "اتصال به وب‌سایت‌های دیگر", + "body": "ارسال درخواست به این سایت‌ها. هر چیزی که افزونه بتواند بخواند ممکن است به آنجا ارسال شود:" + }, + "admin_config": { + "title": "خواندن تنظیمات خود از مدیر سیستم", + "body": "خواندن تنظیماتی که مدیر سیستم برای این افزونه ذخیره کرده است." + }, + "ui_observe": { + "title": "دنبال کردن جای شما در برنامه", + "body": "دانستن اینکه کدام صفحه باز است و واکنش به پیوندهایی که روی آن‌ها کلیک می‌کنید." + }, + "ui_toolbar": { + "title": "افزودن دکمه به نوار ابزار پیام", + "body": "نمایش دکمه‌های خودش بالای پیام باز." + }, + "ui_app_top_banner": { + "title": "نمایش بنر در بالا", + "body": "نمایش یک اطلاعیه در سراسر بالای برنامه." + }, + "ui_email_banner": { + "title": "نمایش اطلاعیه روی پیام‌ها", + "body": "نمایش یک اطلاعیه بالای متن پیام باز." + }, + "ui_email_footer": { + "title": "افزودن محتوا زیر پیام‌ها", + "body": "نمایش محتوای خودش زیر پیام باز." + }, + "ui_contact_cryptokeys": { + "title": "نمایش کلیدهای رمزنگاری مخاطبان", + "body": "نمایش و مدیریت کلیدهای رمزنگاری یک مخاطب." + }, + "ui_email_details": { + "title": "افزودن پنل به جزئیات پیام", + "body": "نمایش پنل خودش کنار یا زیر پیام باز." + }, + "ui_download_file": { + "title": "ذخیره فایل در دستگاه شما", + "body": "ارائه فایل‌هایی که خودش ساخته است برای دانلود." + }, + "ui_message_list_tabs": { + "title": "افزودن برگه به صندوق ورودی", + "body": "مرتب کردن صندوق ورودی شما در برگه‌هایی مانند به‌روزرسانی‌ها یا تبلیغات." + }, + "ui_composer_toolbar": { + "title": "افزودن دکمه به ویرایشگر", + "body": "نمایش دکمه‌های خودش هنگام نوشتن پیام." + }, + "ui_composer_sidebar": { + "title": "افزودن پنل به ویرایشگر", + "body": "نمایش پنل خودش کنار پیامی که می‌نویسید." + }, + "ui_sidebar_widget": { + "title": "افزودن کادر به نوار کناری", + "body": "نمایش محتوای خودش در نوار کناری ایمیل." + }, + "ui_settings_section": { + "title": "افزودن صفحه تنظیمات", + "body": "نمایش صفحه خودش در تنظیمات." + }, + "ui_context_menu": { + "title": "افزودن به منوی پیام", + "body": "افزودن گزینه به منویی که با کلیک راست روی پیام باز می‌شود." + }, + "ui_navigation_rail": { + "title": "افزودن به نوار ناوبری", + "body": "نمایش دکمه خودش در نوار ناوبری." + }, + "ui_keyboard": { + "title": "افزودن میانبرهای صفحه کلید", + "body": "واکنش به میانبرهای صفحه کلیدی که خودش تعریف می‌کند." + }, + "ui_calendar_action": { + "title": "افزودن دکمه به رویدادها", + "body": "نمایش دکمه‌های خودش هنگام ایجاد یا ویرایش رویداد." + }, + "ui_admin_page": { + "title": "افزودن صفحه مدیریت", + "body": "نمایش صفحه خودش در تنظیمات مدیریت." + }, + "app_lifecycle": { + "title": "باخبر شدن از شروع و توقف برنامه", + "body": "باخبر شدن وقتی برنامه شروع می‌شود، آفلاین می‌شود یا از حساب خارج می‌شوید." + } } }, "global_search": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 1702d903c..b40d92c1f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Échec de lautorisation : {error}", "missing_params": "Code dautorisation ou état manquant.", "delivery_failed": "Le résultat de lautorisation na pas pu être transmis à longlet de lapplication. Fermez cette fenêtre et réessayez." + }, + "settings": { + "title": "Plugins", + "description": "Les plugins ajoutés par votre administrateur. Activez-les ou désactivez-les pour votre compte.", + "description_uploads": "Les plugins ajoutent des fonctionnalités au webmail. Activez-les ou désactivez-les pour votre compte, ou installez les vôtres.", + "description_demo": "Les plugins ne sont pas disponibles dans la démo.", + "install_button": "Installer un plugin", + "install_hint": "Choisissez le fichier ZIP d'un plugin.", + "install_hint_approval": "Choisissez le fichier ZIP d'un plugin. Un administrateur doit l'approuver avant que vous puissiez l'activer.", + "install_uploads_off": "Votre administrateur n'autorise pas l'installation de plugins.", + "install_managed_id": "Votre administrateur fournit déjà un plugin portant ce nom, il ne peut donc pas être remplacé.", + "install_invalid": "Ce fichier n'est pas un plugin installable : {details}", + "toast_installed": "Plugin installé. Activez-le ci-dessous.", + "toast_installed_pending": "Plugin installé et envoyé à votre administrateur pour approbation.", + "toast_enabled": "{name} est activé", + "toast_disabled": "{name} est désactivé", + "toast_removed": "{name} a été supprimé", + "toast_forced": "Votre administrateur maintient {name} activé", + "toast_approval_requested": "{name} attend l'approbation de votre administrateur", + "empty_title": "Aucun plugin pour l'instant", + "empty_admin": "Votre administrateur n'a ajouté aucun plugin.", + "empty_uploads": "Installez un plugin à partir d'un fichier ZIP pour commencer.", + "empty_demo": "Les plugins nécessitent un vrai compte, la démo n'en a donc aucun.", + "syncing": "Vérification des plugins auprès du serveur…", + "status_installed": "Désactivé", + "status_enabled": "Démarrage", + "status_running": "Activé", + "status_disabled": "Désactivé", + "status_error": "Problème", + "badge_forced": "Obligatoire", + "badge_managed": "De votre administrateur", + "badge_yours": "Installé par vous", + "badge_awaiting": "En attente d'approbation", + "toggle_label": "Activer ou désactiver {name}", + "forced_note": "Votre administrateur impose ce plugin, il ne peut donc pas être désactivé.", + "permissions": "Ce qu'il peut faire", + "permissions_managed": "Ce qu'il peut faire (approuvé par votre administrateur)", + "settings": "Paramètres", + "remove": "Supprimer", + "remove_title": "Supprimer {name} ?", + "remove_message": "Le plugin et tout ce qu'il a enregistré dans ce navigateur seront supprimés. Vous pourrez le réinstaller plus tard.", + "remove_confirm": "Supprimer", + "error_user_plugins_off": "Votre administrateur a désactivé les plugins installés par les utilisateurs, ce plugin ne s'exécute donc pas.", + "error_approval_unavailable": "Ce plugin a été installé avant que l'approbation soit requise. Supprimez-le et réinstallez-le pour l'envoyer en approbation.", + "error_approval_denied": "Votre administrateur n'a pas approuvé ce plugin.", + "error_approval_pending": "En attente de l'approbation de ce plugin par votre administrateur." + }, + "consent": { + "title": "Autoriser {name} à faire ceci ?", + "intro": "Le plugin demande les autorisations ci-dessous. Il ne les obtient que si vous les accordez.", + "high_risk_note": "Les éléments signalés donnent au plugin accès au contenu de vos e-mails ou lui permettent d'envoyer des données ailleurs. Ne les autorisez que pour des plugins de confiance.", + "revoke_hint": "Vous pouvez désactiver ou supprimer le plugin à tout moment dans les Paramètres.", + "deny": "Ne pas autoriser", + "allow": "Autoriser" + }, + "permissions": { + "none": "Il n'a besoin d'aucune autorisation particulière.", + "high_risk": "Prudence", + "email_read": { + "title": "Lire vos e-mails", + "body": "Voir les expéditeurs, les destinataires, les objets et le texte complet des messages que vous ouvrez et de la liste des messages." + }, + "email_write": { + "title": "Modifier les étiquettes et indicateurs de vos e-mails", + "body": "Ajouter ou retirer des étiquettes, des indicateurs et d'autres mots-clés sur vos messages." + }, + "email_send": { + "title": "Modifier les e-mails avant leur envoi", + "body": "Lire et modifier les destinataires, l'objet, le texte et les pièces jointes des messages que vous rédigez, et empêcher l'envoi d'un message." + }, + "crypto_full": { + "title": "Accès complet à cette application", + "body": "S'exécute avec les mêmes droits que le webmail lui-même. Il peut lire tous vos e-mails et vos clés enregistrées, et agir en votre nom. N'autorisez ceci que pour des plugins en qui vous avez entièrement confiance." + }, + "email_raw_send": { + "title": "Envoyer des messages qu'il a lui-même créés", + "body": "Envoyer des messages complets, par exemple signés ou chiffrés, depuis votre compte." + }, + "email_blob_read": { + "title": "Lire le contenu brut des messages", + "body": "Lire les données d'origine de vos messages et pièces jointes, ainsi que de chaque fichier que vous joignez avant l'envoi." + }, + "email_blob_write": { + "title": "Remplacer les pièces jointes avant l'envoi", + "body": "Téléverser des fichiers sur votre serveur de messagerie et remplacer un fichier joint par un autre avant l'envoi du message." + }, + "email_render_takeover": { + "title": "Modifier l'affichage des messages", + "body": "Remplacer le texte des messages que vous ouvrez et de la liste des messages, par exemple pour afficher un contenu déchiffré." + }, + "calendar_read": { + "title": "Lire votre calendrier", + "body": "Voir vos calendriers et leurs événements." + }, + "calendar_write": { + "title": "Modifier votre calendrier", + "body": "Créer, modifier et supprimer des événements." + }, + "contacts_read": { + "title": "Lire vos contacts", + "body": "Rechercher et lire vos carnets d'adresses." + }, + "contacts_write": { + "title": "Modifier vos contacts", + "body": "Créer, modifier et supprimer des contacts et des carnets d'adresses." + }, + "files_read": { + "title": "Lire vos fichiers", + "body": "Parcourir et ouvrir les fichiers stockés dans votre compte." + }, + "files_write": { + "title": "Modifier vos fichiers", + "body": "Téléverser, renommer, déplacer et supprimer des fichiers dans votre compte." + }, + "identity_read": { + "title": "Voir vos adresses d'expéditeur", + "body": "Voir les adresses depuis lesquelles vous envoyez et leurs signatures." + }, + "identity_write": { + "title": "Modifier vos adresses d'expéditeur", + "body": "Créer, modifier et supprimer des adresses d'expéditeur et des signatures." + }, + "filters_read": { + "title": "Lire vos filtres de messagerie", + "body": "Voir les règles qui trient vos e-mails entrants." + }, + "filters_write": { + "title": "Modifier vos filtres de messagerie", + "body": "Ajouter des règles à vos filtres côté serveur. Ces règles peuvent déplacer, transférer ou supprimer les e-mails entrants." + }, + "tasks_read": { + "title": "Lire vos tâches", + "body": "Voir vos listes de tâches et vos tâches." + }, + "tasks_write": { + "title": "Modifier vos tâches", + "body": "Créer, modifier, terminer et supprimer des tâches." + }, + "templates_read": { + "title": "Lire vos modèles", + "body": "Voir vos modèles de message enregistrés." + }, + "templates_write": { + "title": "Modifier vos modèles", + "body": "Créer, modifier et supprimer des modèles de message." + }, + "smime_read": { + "title": "Voir votre statut S/MIME", + "body": "Voir si vous avez des clés et certificats S/MIME, sans accès aux clés." + }, + "vacation_read": { + "title": "Voir votre réponse d'absence", + "body": "Voir si votre réponse automatique est activée et ce qu'elle dit." + }, + "vacation_write": { + "title": "Modifier votre réponse d'absence", + "body": "Activer ou désactiver votre réponse automatique et modifier son texte et ses dates." + }, + "settings_read": { + "title": "Lire vos paramètres", + "body": "Voir vos préférences et la définition de vos étiquettes." + }, + "settings_write": { + "title": "Modifier vos paramètres", + "body": "Ajouter et réordonner des étiquettes et modifier vos préférences." + }, + "security_read": { + "title": "Voir la sécurité de votre compte", + "body": "Voir si la connexion à deux facteurs et le chiffrement sont activés, sans accès aux secrets." + }, + "auth_observe": { + "title": "Savoir quand vous vous connectez", + "body": "Être averti lorsque vous vous connectez, changez de compte ou que votre session est renouvelée." + }, + "auth_emit": { + "title": "Vous déconnecter", + "body": "Vous déconnecter du webmail." + }, + "account_read": { + "title": "Voir vos comptes", + "body": "Voir les comptes que vous avez ajoutés, avec leurs adresses et leurs serveurs." + }, + "http_post": { + "title": "Utiliser le serveur du webmail pour vous", + "body": "Appeler ces points de terminaison du serveur avec votre connexion :" + }, + "http_fetch": { + "title": "Se connecter à d'autres sites web", + "body": "Envoyer des requêtes à ces sites. Tout ce que le plugin peut lire peut y être envoyé :" + }, + "admin_config": { + "title": "Lire ses paramètres définis par votre administrateur", + "body": "Lire les paramètres que votre administrateur a enregistrés pour ce plugin." + }, + "ui_observe": { + "title": "Suivre où vous êtes dans l'application", + "body": "Savoir quelle page est ouverte et réagir aux liens sur lesquels vous cliquez." + }, + "ui_toolbar": { + "title": "Ajouter des boutons à la barre d'outils des messages", + "body": "Afficher ses propres boutons au-dessus d'un message ouvert." + }, + "ui_app_top_banner": { + "title": "Afficher une bannière en haut", + "body": "Afficher un avis sur toute la largeur en haut de l'application." + }, + "ui_email_banner": { + "title": "Afficher des avis sur les messages", + "body": "Afficher un avis au-dessus du texte d'un message ouvert." + }, + "ui_email_footer": { + "title": "Ajouter du contenu sous les messages", + "body": "Afficher son propre contenu sous un message ouvert." + }, + "ui_contact_cryptokeys": { + "title": "Afficher les clés de chiffrement des contacts", + "body": "Afficher et gérer les clés de chiffrement d'un contact." + }, + "ui_email_details": { + "title": "Ajouter des panneaux aux détails des messages", + "body": "Afficher son propre panneau à côté ou en dessous d'un message ouvert." + }, + "ui_download_file": { + "title": "Enregistrer des fichiers sur votre appareil", + "body": "Proposer au téléchargement des fichiers qu'il a créés." + }, + "ui_message_list_tabs": { + "title": "Ajouter des onglets à votre boîte de réception", + "body": "Répartir votre boîte de réception en onglets comme Mises à jour ou Promotions." + }, + "ui_composer_toolbar": { + "title": "Ajouter des boutons à l'éditeur", + "body": "Afficher ses propres boutons pendant que vous rédigez un message." + }, + "ui_composer_sidebar": { + "title": "Ajouter un panneau à l'éditeur", + "body": "Afficher son propre panneau à côté du message que vous rédigez." + }, + "ui_sidebar_widget": { + "title": "Ajouter un encadré à la barre latérale", + "body": "Afficher son propre contenu dans la barre latérale de la messagerie." + }, + "ui_settings_section": { + "title": "Ajouter une page de paramètres", + "body": "Afficher sa propre page dans les Paramètres." + }, + "ui_context_menu": { + "title": "Ajouter au menu des messages", + "body": "Ajouter des entrées au menu qui s'ouvre lorsque vous faites un clic droit sur un message." + }, + "ui_navigation_rail": { + "title": "Ajouter à la barre de navigation", + "body": "Afficher son propre bouton dans la barre de navigation." + }, + "ui_keyboard": { + "title": "Ajouter des raccourcis clavier", + "body": "Réagir aux raccourcis clavier qu'il définit." + }, + "ui_calendar_action": { + "title": "Ajouter des boutons aux événements", + "body": "Afficher ses propres boutons lorsque vous créez ou modifiez un événement." + }, + "ui_admin_page": { + "title": "Ajouter une page d'administration", + "body": "Afficher sa propre page dans les paramètres d'administration." + }, + "app_lifecycle": { + "title": "Savoir quand l'application démarre et s'arrête", + "body": "Être averti lorsque l'application démarre, passe hors ligne ou que vous vous déconnectez." + } } }, "global_search": { diff --git a/locales/he/common.json b/locales/he/common.json index d2204f024..b7ff75a9b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3971,6 +3971,276 @@ "failed_detail": "האימות נכשל: {error}", "missing_params": "חסר קוד אימות או מצב.", "delivery_failed": "לא ניתן היה למסור את תוצאת האימות לכרטיסיית האפליקציה. סגרו חלון זה ונסו שוב." + }, + "settings": { + "title": "תוספים", + "description": "תוספים שמנהל המערכת הוסיף. הפעילו או כבו אותם עבור החשבון שלכם.", + "description_uploads": "תוספים מוסיפים יכולות ל-Webmail. הפעילו או כבו אותם עבור החשבון שלכם, או התקינו תוספים משלכם.", + "description_demo": "תוספים אינם זמינים במצב הדגמה.", + "install_button": "התקנת תוסף", + "install_hint": "בחרו קובץ ZIP של תוסף.", + "install_hint_approval": "בחרו קובץ ZIP של תוסף. מנהל המערכת צריך לאשר אותו לפני שתוכלו להפעיל אותו.", + "install_uploads_off": "מנהל המערכת אינו מאפשר התקנת תוספים.", + "install_managed_id": "מנהל המערכת כבר מספק תוסף בשם הזה, ולכן אי אפשר להחליף אותו.", + "install_invalid": "הקובץ הזה אינו תוסף שניתן להתקין: {details}", + "toast_installed": "התוסף הותקן. הפעילו אותו למטה.", + "toast_installed_pending": "התוסף הותקן ונשלח לאישור מנהל המערכת.", + "toast_enabled": "{name} פועל", + "toast_disabled": "{name} כבוי", + "toast_removed": "{name} הוסר", + "toast_forced": "מנהל המערכת משאיר את {name} פועל", + "toast_approval_requested": "{name} ממתין לאישור מנהל המערכת", + "empty_title": "אין עדיין תוספים", + "empty_admin": "מנהל המערכת לא הוסיף תוספים.", + "empty_uploads": "כדי להתחיל, התקינו תוסף מקובץ ZIP.", + "empty_demo": "תוספים דורשים חשבון אמיתי, ולכן אין תוספים במצב הדגמה.", + "syncing": "בודק תוספים מול השרת…", + "status_installed": "כבוי", + "status_enabled": "בהפעלה", + "status_running": "פועל", + "status_disabled": "כבוי", + "status_error": "תקלה", + "badge_forced": "חובה", + "badge_managed": "ממנהל המערכת", + "badge_yours": "הותקן על ידיכם", + "badge_awaiting": "ממתין לאישור", + "toggle_label": "הפעלה או כיבוי של {name}", + "forced_note": "מנהל המערכת מחייב את התוסף הזה, ולכן אי אפשר לכבות אותו.", + "permissions": "מה הוא יכול לעשות", + "permissions_managed": "מה הוא יכול לעשות (באישור מנהל המערכת)", + "settings": "הגדרות", + "remove": "הסרה", + "remove_title": "להסיר את {name}?", + "remove_message": "התוסף וכל מה שהוא שמר בדפדפן הזה יימחקו. אפשר להתקין אותו שוב מאוחר יותר.", + "remove_confirm": "הסרה", + "error_user_plugins_off": "מנהל המערכת כיבה תוספים שמשתמשים התקינו, ולכן התוסף הזה לא פועל.", + "error_approval_unavailable": "התוסף הזה הותקן לפני שנדרש אישור. הסירו אותו והתקינו אותו מחדש כדי לשלוח אותו לאישור.", + "error_approval_denied": "מנהל המערכת לא אישר את התוסף הזה.", + "error_approval_pending": "ממתין לאישור התוסף הזה על ידי מנהל המערכת." + }, + "consent": { + "title": "לאפשר ל-{name} לעשות זאת?", + "intro": "התוסף מבקש את ההרשאות שלהלן. הוא יקבל אותן רק אם תאפשרו זאת.", + "high_risk_note": "פריטים מסומנים נותנים לתוסף גישה לתוכן הדואר שלכם או מאפשרים לו לשלוח נתונים למקום אחר. אפשרו אותם רק לתוספים שאתם סומכים עליהם.", + "revoke_hint": "אפשר לכבות את התוסף או להסיר אותו בהגדרות בכל עת.", + "deny": "לא לאפשר", + "allow": "לאפשר" + }, + "permissions": { + "none": "אינו צריך הרשאות מיוחדות.", + "high_risk": "דורש זהירות", + "email_read": { + "title": "קריאת הדואר שלכם", + "body": "צפייה בשולחים, בנמענים, בנושאים ובטקסט המלא של הודעות שאתם פותחים ושל רשימת ההודעות." + }, + "email_write": { + "title": "שינוי תגים וסימונים בדואר שלכם", + "body": "הוספה או הסרה של תגים, סימונים ומילות מפתח אחרות בהודעות שלכם." + }, + "email_send": { + "title": "שינוי דואר לפני שליחתו", + "body": "קריאה ושינוי של הנמענים, הנושא, הטקסט והקבצים המצורפים של הודעות שאתם כותבים, ומניעת שליחה של הודעה." + }, + "crypto_full": { + "title": "גישה מלאה לאפליקציה הזו", + "body": "פועל עם אותן הרשאות כמו ה-Webmail עצמו. הוא יכול לקרוא את כל הדואר והמפתחות השמורים שלכם ולפעול בשמכם. אפשרו זאת רק לתוספים שאתם סומכים עליהם לגמרי." + }, + "email_raw_send": { + "title": "שליחת הודעות שהוא בנה בעצמו", + "body": "שליחת הודעות שלמות, למשל חתומות או מוצפנות, מהחשבון שלכם." + }, + "email_blob_read": { + "title": "קריאת התוכן הגולמי של הודעות", + "body": "קריאת הנתונים המקוריים של ההודעות והקבצים המצורפים שלכם, ושל כל קובץ שאתם מצרפים לפני שליחתו." + }, + "email_blob_write": { + "title": "החלפת קבצים מצורפים לפני שליחה", + "body": "העלאת קבצים לשרת הדואר שלכם והחלפת קובץ שצירפתם בקובץ אחר לפני שההודעה נשלחת." + }, + "email_render_takeover": { + "title": "שינוי אופן הצגת ההודעות", + "body": "החלפת הטקסט של הודעות שאתם פותחים ושל רשימת ההודעות, למשל כדי להציג תוכן מפוענח." + }, + "calendar_read": { + "title": "קריאת היומן שלכם", + "body": "צפייה ביומנים שלכם ובאירועים שבהם." + }, + "calendar_write": { + "title": "שינוי היומן שלכם", + "body": "יצירה, שינוי ומחיקה של אירועים." + }, + "contacts_read": { + "title": "קריאת אנשי הקשר שלכם", + "body": "חיפוש וקריאה בספרי הכתובות שלכם." + }, + "contacts_write": { + "title": "שינוי אנשי הקשר שלכם", + "body": "יצירה, שינוי ומחיקה של אנשי קשר וספרי כתובות." + }, + "files_read": { + "title": "קריאת הקבצים שלכם", + "body": "עיון בקבצים השמורים בחשבון שלכם ופתיחתם." + }, + "files_write": { + "title": "שינוי הקבצים שלכם", + "body": "העלאה, שינוי שם, העברה ומחיקה של קבצים בחשבון שלכם." + }, + "identity_read": { + "title": "צפייה בכתובות השולח שלכם", + "body": "צפייה בכתובות שמהן אתם שולחים ובחתימות שלהן." + }, + "identity_write": { + "title": "שינוי כתובות השולח שלכם", + "body": "יצירה, שינוי ומחיקה של כתובות שולח וחתימות." + }, + "filters_read": { + "title": "קריאת מסנני הדואר שלכם", + "body": "צפייה בכללים שממיינים את הדואר הנכנס שלכם." + }, + "filters_write": { + "title": "שינוי מסנני הדואר שלכם", + "body": "הוספת כללים למסננים בצד השרת. כללים כאלה יכולים להעביר לתיקייה, להעביר הלאה או למחוק דואר נכנס." + }, + "tasks_read": { + "title": "קריאת המשימות שלכם", + "body": "צפייה ברשימות המשימות ובמשימות שלכם." + }, + "tasks_write": { + "title": "שינוי המשימות שלכם", + "body": "יצירה, שינוי, השלמה ומחיקה של משימות." + }, + "templates_read": { + "title": "קריאת התבניות שלכם", + "body": "צפייה בתבניות ההודעה השמורות שלכם." + }, + "templates_write": { + "title": "שינוי התבניות שלכם", + "body": "יצירה, שינוי ומחיקה של תבניות הודעה." + }, + "smime_read": { + "title": "צפייה במצב S/MIME שלכם", + "body": "בדיקה אם יש לכם מפתחות ואישורי S/MIME, ללא גישה למפתחות." + }, + "vacation_read": { + "title": "צפייה בתשובת ההיעדרות שלכם", + "body": "בדיקה אם התשובה האוטומטית שלכם פעילה ומה כתוב בה." + }, + "vacation_write": { + "title": "שינוי תשובת ההיעדרות שלכם", + "body": "הפעלה או כיבוי של התשובה האוטומטית ושינוי הטקסט והתאריכים שלה." + }, + "settings_read": { + "title": "קריאת ההגדרות שלכם", + "body": "צפייה בהעדפות ובהגדרות התגים שלכם." + }, + "settings_write": { + "title": "שינוי ההגדרות שלכם", + "body": "הוספה וסידור מחדש של תגים ושינוי ההעדפות שלכם." + }, + "security_read": { + "title": "צפייה באבטחת החשבון שלכם", + "body": "בדיקה אם אימות דו-שלבי והצפנה פעילים, ללא גישה לסודות." + }, + "auth_observe": { + "title": "לדעת מתי אתם נכנסים", + "body": "לקבל עדכון כשאתם נכנסים, מחליפים חשבון או כשההפעלה שלכם מתחדשת." + }, + "auth_emit": { + "title": "ניתוק שלכם", + "body": "ניתוק שלכם מה-Webmail." + }, + "account_read": { + "title": "צפייה בחשבונות שלכם", + "body": "צפייה בחשבונות שהוספתם, עם הכתובות והשרתים שלהם." + }, + "http_post": { + "title": "שימוש בשרת ה-Webmail בשמכם", + "body": "פנייה לנקודות הקצה האלה בשרת עם פרטי ההתחברות שלכם:" + }, + "http_fetch": { + "title": "התחברות לאתרים אחרים", + "body": "שליחת בקשות לאתרים האלה. כל מה שהתוסף יכול לקרוא עלול להישלח אליהם:" + }, + "admin_config": { + "title": "קריאת ההגדרות שלו ממנהל המערכת", + "body": "קריאת ההגדרות שמנהל המערכת שמר עבור התוסף הזה." + }, + "ui_observe": { + "title": "מעקב אחר המיקום שלכם באפליקציה", + "body": "לדעת איזה עמוד פתוח ולהגיב לקישורים שאתם לוחצים עליהם." + }, + "ui_toolbar": { + "title": "הוספת כפתורים לסרגל הכלים של ההודעה", + "body": "הצגת כפתורים משלו מעל הודעה פתוחה." + }, + "ui_app_top_banner": { + "title": "הצגת באנר בחלק העליון", + "body": "הצגת הודעה לרוחב החלק העליון של האפליקציה." + }, + "ui_email_banner": { + "title": "הצגת הודעות על גבי הודעות דואר", + "body": "הצגת הודעה מעל הטקסט של הודעה פתוחה." + }, + "ui_email_footer": { + "title": "הוספת תוכן מתחת להודעות", + "body": "הצגת תוכן משלו מתחת להודעה פתוחה." + }, + "ui_contact_cryptokeys": { + "title": "הצגת מפתחות הצפנה של אנשי קשר", + "body": "הצגה וניהול של מפתחות ההצפנה של איש קשר." + }, + "ui_email_details": { + "title": "הוספת חלוניות לפרטי ההודעה", + "body": "הצגת חלונית משלו לצד הודעה פתוחה או מתחתיה." + }, + "ui_download_file": { + "title": "שמירת קבצים במכשיר שלכם", + "body": "הצעת קבצים שהוא יצר להורדה." + }, + "ui_message_list_tabs": { + "title": "הוספת לשוניות לדואר הנכנס", + "body": "מיון הדואר הנכנס שלכם ללשוניות כמו עדכונים או קידומי מכירות." + }, + "ui_composer_toolbar": { + "title": "הוספת כפתורים לעורך", + "body": "הצגת כפתורים משלו בזמן שאתם כותבים הודעה." + }, + "ui_composer_sidebar": { + "title": "הוספת חלונית לעורך", + "body": "הצגת חלונית משלו לצד ההודעה שאתם כותבים." + }, + "ui_sidebar_widget": { + "title": "הוספת תיבה לסרגל הצד", + "body": "הצגת תוכן משלו בסרגל הצד של הדואר." + }, + "ui_settings_section": { + "title": "הוספת עמוד הגדרות", + "body": "הצגת עמוד משלו בהגדרות." + }, + "ui_context_menu": { + "title": "הוספה לתפריט ההודעה", + "body": "הוספת פריטים לתפריט שנפתח בלחיצה ימנית על הודעה." + }, + "ui_navigation_rail": { + "title": "הוספה לסרגל הניווט", + "body": "הצגת כפתור משלו בסרגל הניווט." + }, + "ui_keyboard": { + "title": "הוספת קיצורי מקשים", + "body": "תגובה לקיצורי מקשים שהוא מגדיר." + }, + "ui_calendar_action": { + "title": "הוספת כפתורים לאירועים", + "body": "הצגת כפתורים משלו כשאתם יוצרים או עורכים אירוע." + }, + "ui_admin_page": { + "title": "הוספת עמוד ניהול", + "body": "הצגת עמוד משלו בהגדרות הניהול." + }, + "app_lifecycle": { + "title": "לדעת מתי האפליקציה מופעלת ונעצרת", + "body": "לקבל עדכון כשהאפליקציה מופעלת, עוברת למצב לא מקוון או כשאתם מתנתקים." + } } }, "global_search": { diff --git a/locales/hu/common.json b/locales/hu/common.json index e9e424c24..bac4f8302 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "A hitelesítés sikertelen: {error}", "missing_params": "Hiányzik a hitelesítési kód vagy az állapot.", "delivery_failed": "A hitelesítés eredményét nem sikerült átadni az alkalmazás lapjának. Zárja be ezt az ablakot, és próbálja újra." + }, + "settings": { + "title": "Bővítmények", + "description": "A rendszergazda által hozzáadott bővítmények. A fiókodhoz be- vagy kikapcsolhatod őket.", + "description_uploads": "A bővítmények új funkciókat adnak a webmailhez. A fiókodhoz be- vagy kikapcsolhatod őket, vagy telepíthetsz sajátot.", + "description_demo": "A bővítmények nem érhetők el a demóban.", + "install_button": "Bővítmény telepítése", + "install_hint": "Válassz egy bővítményt tartalmazó ZIP-fájlt.", + "install_hint_approval": "Válassz egy bővítményt tartalmazó ZIP-fájlt. Bekapcsolás előtt a rendszergazdának jóvá kell hagynia.", + "install_uploads_off": "A rendszergazda nem engedélyezi bővítmények telepítését.", + "install_managed_id": "A rendszergazda már biztosít egy ilyen nevű bővítményt, ezért nem cserélhető le.", + "install_invalid": "Ez a fájl nem telepíthető bővítmény: {details}", + "toast_installed": "Bővítmény telepítve. Lent kapcsolhatod be.", + "toast_installed_pending": "Bővítmény telepítve, és jóváhagyásra elküldve a rendszergazdának.", + "toast_enabled": "Bővítmény bekapcsolva: {name}", + "toast_disabled": "Bővítmény kikapcsolva: {name}", + "toast_removed": "Bővítmény eltávolítva: {name}", + "toast_forced": "A rendszergazda bekapcsolva tartja ezt a bővítményt: {name}", + "toast_approval_requested": "Rendszergazdai jóváhagyásra vár: {name}", + "empty_title": "Még nincsenek bővítmények", + "empty_admin": "A rendszergazda még nem adott hozzá bővítményt.", + "empty_uploads": "Kezdésként telepíts egy bővítményt ZIP-fájlból.", + "empty_demo": "A bővítményekhez valódi fiók kell, ezért a demóban nincsenek.", + "syncing": "Bővítmények ellenőrzése a szerveren…", + "status_installed": "Ki", + "status_enabled": "Indítás", + "status_running": "Be", + "status_disabled": "Ki", + "status_error": "Hiba", + "badge_forced": "Kötelező", + "badge_managed": "A rendszergazdától", + "badge_yours": "Saját telepítés", + "badge_awaiting": "Jóváhagyásra vár", + "toggle_label": "Bővítmény be- vagy kikapcsolása: {name}", + "forced_note": "A rendszergazda előírja ezt a bővítményt, ezért nem kapcsolható ki.", + "permissions": "Mire képes", + "permissions_managed": "Mire képes (a rendszergazda jóváhagyta)", + "settings": "Beállítások", + "remove": "Eltávolítás", + "remove_title": "{name} eltávolítása?", + "remove_message": "A bővítmény és minden, amit ebben a böngészőben tárolt, törlődik. Később újra telepítheted.", + "remove_confirm": "Eltávolítás", + "error_user_plugins_off": "A rendszergazda kikapcsolta a felhasználók által telepített bővítményeket, ezért ez a bővítmény nem fut.", + "error_approval_unavailable": "Ezt a bővítményt még azelőtt telepítetted, hogy jóváhagyás kellett volna. Távolítsd el, majd telepítsd újra, hogy jóváhagyásra kerüljön.", + "error_approval_denied": "A rendszergazda nem hagyta jóvá ezt a bővítményt.", + "error_approval_pending": "Várakozás arra, hogy a rendszergazda jóváhagyja ezt a bővítményt." + }, + "consent": { + "title": "{name}: engedélyezed ezeket a műveleteket?", + "intro": "A bővítmény az alábbi engedélyeket kéri. Csak akkor kapja meg őket, ha engedélyezed.", + "high_risk_note": "A megjelölt elemek hozzáférést adnak a bővítménynek a leveleid tartalmához, vagy lehetővé teszik, hogy adatokat küldjön máshová. Csak megbízható bővítményeknek engedélyezd őket.", + "revoke_hint": "A bővítményt bármikor kikapcsolhatod vagy eltávolíthatod a Beállításokban.", + "deny": "Nem engedélyezem", + "allow": "Engedélyezés" + }, + "permissions": { + "none": "Nincs szüksége különleges engedélyre.", + "high_risk": "Óvatosan", + "email_read": { + "title": "Leveleid olvasása", + "body": "Látja a megnyitott üzenetek és az üzenetlista feladóit, címzettjeit, tárgyát és teljes szövegét." + }, + "email_write": { + "title": "Címkék és jelölők módosítása a leveleiden", + "body": "Címkéket, jelölőket és más kulcsszavakat ad hozzá az üzeneteidhez, vagy eltávolítja őket." + }, + "email_send": { + "title": "Levelek módosítása küldés előtt", + "body": "Olvassa és módosítja az általad írt üzenetek címzettjeit, tárgyát, szövegét és mellékleteit, és megakadályozhatja egy üzenet elküldését." + }, + "crypto_full": { + "title": "Teljes hozzáférés az alkalmazáshoz", + "body": "Ugyanazokkal a jogokkal fut, mint maga a webmail. Olvashatja az összes leveledet és tárolt kulcsodat, és a nevedben járhat el. Csak olyan bővítménynek engedélyezd, amelyben teljesen megbízol." + }, + "email_raw_send": { + "title": "Saját maga által összeállított üzenetek küldése", + "body": "Teljes üzeneteket küld a fiókodból, például aláírt vagy titkosított üzeneteket." + }, + "email_blob_read": { + "title": "Üzenetek nyers tartalmának olvasása", + "body": "Olvassa az üzeneteid és mellékleteid eredeti adatait, valamint minden fájlét, amelyet küldés előtt csatolsz." + }, + "email_blob_write": { + "title": "Mellékletek cseréje küldés előtt", + "body": "Fájlokat tölt fel a levelezőszerveredre, és az üzenet elküldése előtt egy csatolt fájlt másikra cserél." + }, + "email_render_takeover": { + "title": "Üzenetek megjelenítésének módosítása", + "body": "Lecseréli a megnyitott üzenetek és az üzenetlista szövegét, például a visszafejtett tartalom megjelenítéséhez." + }, + "calendar_read": { + "title": "Naptárad olvasása", + "body": "Látja a naptáraidat és azok eseményeit." + }, + "calendar_write": { + "title": "Naptárad módosítása", + "body": "Eseményeket hoz létre, módosít és töröl." + }, + "contacts_read": { + "title": "Névjegyeid olvasása", + "body": "Keres a címjegyzékeidben, és olvassa őket." + }, + "contacts_write": { + "title": "Névjegyeid módosítása", + "body": "Névjegyeket és címjegyzékeket hoz létre, módosít és töröl." + }, + "files_read": { + "title": "Fájljaid olvasása", + "body": "Böngészi és megnyitja a fiókodban tárolt fájlokat." + }, + "files_write": { + "title": "Fájljaid módosítása", + "body": "Fájlokat tölt fel, nevez át, helyez át és töröl a fiókodban." + }, + "identity_read": { + "title": "Küldő azonosságaid megtekintése", + "body": "Látja a címeket, amelyekről küldesz, és azok aláírásait." + }, + "identity_write": { + "title": "Küldő azonosságaid módosítása", + "body": "Küldő azonosságokat és aláírásokat hoz létre, módosít és töröl." + }, + "filters_read": { + "title": "Levélszűrőid olvasása", + "body": "Látja a szabályokat, amelyek a beérkező leveleidet rendezik." + }, + "filters_write": { + "title": "Levélszűrőid módosítása", + "body": "Szabályokat ad a szerveroldali szűrőidhez. Az ilyen szabályok áthelyezhetik, továbbíthatják vagy törölhetik a beérkező leveleket." + }, + "tasks_read": { + "title": "Feladataid olvasása", + "body": "Látja a feladatlistáidat és a feladataidat." + }, + "tasks_write": { + "title": "Feladataid módosítása", + "body": "Feladatokat hoz létre, módosít, teljesít és töröl." + }, + "templates_read": { + "title": "Sablonjaid olvasása", + "body": "Látja a mentett üzenetsablonjaidat." + }, + "templates_write": { + "title": "Sablonjaid módosítása", + "body": "Üzenetsablonokat hoz létre, módosít és töröl." + }, + "smime_read": { + "title": "S/MIME állapotod megtekintése", + "body": "Látja, hogy vannak-e S/MIME kulcsaid és tanúsítványaid, a kulcsokhoz való hozzáférés nélkül." + }, + "vacation_read": { + "title": "Távolléti válaszod megtekintése", + "body": "Látja, hogy be van-e kapcsolva az automatikus válaszod, és mi a szövege." + }, + "vacation_write": { + "title": "Távolléti válaszod módosítása", + "body": "Be- és kikapcsolja az automatikus válaszodat, és módosítja a szövegét és dátumait." + }, + "settings_read": { + "title": "Beállításaid olvasása", + "body": "Látja a beállításaidat és a címkék meghatározásait." + }, + "settings_write": { + "title": "Beállításaid módosítása", + "body": "Címkéket ad hozzá, átrendezi őket, és módosítja a beállításaidat." + }, + "security_read": { + "title": "Fiókod biztonságának megtekintése", + "body": "Látja, hogy be van-e kapcsolva a kétfaktoros hitelesítés és a titkosítás, a titkos adatokhoz való hozzáférés nélkül." + }, + "auth_observe": { + "title": "Tudomást szerez a bejelentkezésedről", + "body": "Értesül, amikor bejelentkezel, fiókot váltasz, vagy megújul a munkameneted." + }, + "auth_emit": { + "title": "Kijelentkeztetés", + "body": "Kijelentkeztethet a webmailből." + }, + "account_read": { + "title": "Fiókjaid megtekintése", + "body": "Látja a hozzáadott fiókjaidat a címükkel és szerverükkel együtt." + }, + "http_post": { + "title": "A webmail szerverének használata a nevedben", + "body": "Meghívja ezeket a szervervégpontokat a bejelentkezéseddel:" + }, + "http_fetch": { + "title": "Kapcsolódás más webhelyekhez", + "body": "Kéréseket küld ezekre a webhelyekre. Bármi, amit a bővítmény olvashat, ide is eljuthat:" + }, + "admin_config": { + "title": "A rendszergazdától kapott beállítások olvasása", + "body": "Olvassa a beállításokat, amelyeket a rendszergazda mentett ehhez a bővítményhez." + }, + "ui_observe": { + "title": "Követi, hol jársz az alkalmazásban", + "body": "Tudja, melyik oldal van megnyitva, és reagál a hivatkozásokra, amelyekre kattintasz." + }, + "ui_toolbar": { + "title": "Gombok az üzenet eszköztárán", + "body": "Saját gombokat jelenít meg egy megnyitott üzenet felett." + }, + "ui_app_top_banner": { + "title": "Szalag megjelenítése felül", + "body": "Értesítést jelenít meg az alkalmazás tetején." + }, + "ui_email_banner": { + "title": "Értesítések az üzeneteken", + "body": "Értesítést jelenít meg egy megnyitott üzenet szövege felett." + }, + "ui_email_footer": { + "title": "Tartalom az üzenetek alatt", + "body": "Saját tartalmat jelenít meg egy megnyitott üzenet alatt." + }, + "ui_contact_cryptokeys": { + "title": "Névjegyek titkosítási kulcsainak megjelenítése", + "body": "Megjeleníti és kezeli egy névjegy titkosítási kulcsait." + }, + "ui_email_details": { + "title": "Panelek az üzenet részleteiben", + "body": "Saját panelt jelenít meg egy megnyitott üzenet mellett vagy alatt." + }, + "ui_download_file": { + "title": "Fájlok mentése az eszközödre", + "body": "Letöltésre kínálja az általa létrehozott fájlokat." + }, + "ui_message_list_tabs": { + "title": "Lapok a Beérkező levelekben", + "body": "Lapokra bontja a Beérkező leveleket, például Frissítések vagy Promóciók." + }, + "ui_composer_toolbar": { + "title": "Gombok a szerkesztőben", + "body": "Saját gombokat jelenít meg, miközben üzenetet írsz." + }, + "ui_composer_sidebar": { + "title": "Panel a szerkesztőben", + "body": "Saját panelt jelenít meg az általad írt üzenet mellett." + }, + "ui_sidebar_widget": { + "title": "Doboz az oldalsávon", + "body": "Saját tartalmat jelenít meg a levelezés oldalsávján." + }, + "ui_settings_section": { + "title": "Beállítási oldal hozzáadása", + "body": "Saját oldalt jelenít meg a Beállításokban." + }, + "ui_context_menu": { + "title": "Elemek az üzenet menüjében", + "body": "Elemeket ad ahhoz a menühöz, amely egy üzenetre jobb gombbal kattintva nyílik meg." + }, + "ui_navigation_rail": { + "title": "Gomb a navigációs sávon", + "body": "Saját gombot jelenít meg a navigációs sávon." + }, + "ui_keyboard": { + "title": "Billentyűparancsok hozzáadása", + "body": "Reagál az általa meghatározott billentyűparancsokra." + }, + "ui_calendar_action": { + "title": "Gombok az eseményeknél", + "body": "Saját gombokat jelenít meg, amikor eseményt hozol létre vagy szerkesztesz." + }, + "ui_admin_page": { + "title": "Adminisztrációs oldal hozzáadása", + "body": "Saját oldalt jelenít meg az adminisztrációs beállításokban." + }, + "app_lifecycle": { + "title": "Tudomást szerez az alkalmazás indulásáról és leállásáról", + "body": "Értesül, amikor az alkalmazás elindul, offline módba vált, vagy kijelentkezel." + } } }, "global_search": { diff --git a/locales/it/common.json b/locales/it/common.json index 87d86221c..48421a212 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorizzazione non riuscita: {error}", "missing_params": "Codice di autorizzazione o stato mancante.", "delivery_failed": "Impossibile consegnare il risultato dellautorizzazione alla scheda dellapp. Chiudi questa finestra e riprova." + }, + "settings": { + "title": "Plugin", + "description": "Plugin aggiunti dal tuo amministratore. Attivali o disattivali per il tuo account.", + "description_uploads": "I plugin aggiungono funzioni alla webmail. Attivali o disattivali per il tuo account, oppure installa i tuoi.", + "description_demo": "I plugin non sono disponibili nella demo.", + "install_button": "Installa plugin", + "install_hint": "Scegli il file ZIP di un plugin.", + "install_hint_approval": "Scegli il file ZIP di un plugin. Un amministratore deve approvarlo prima che tu possa attivarlo.", + "install_uploads_off": "Il tuo amministratore non consente di installare plugin.", + "install_managed_id": "Il tuo amministratore fornisce già un plugin con questo nome, quindi non può essere sostituito.", + "install_invalid": "Questo file non è un plugin installabile: {details}", + "toast_installed": "Plugin installato. Attivalo qui sotto.", + "toast_installed_pending": "Plugin installato e inviato al tuo amministratore per l'approvazione.", + "toast_enabled": "{name} è attivo", + "toast_disabled": "{name} è disattivato", + "toast_removed": "{name} è stato rimosso", + "toast_forced": "Il tuo amministratore mantiene attivo {name}", + "toast_approval_requested": "{name} è in attesa dell'approvazione del tuo amministratore", + "empty_title": "Ancora nessun plugin", + "empty_admin": "Il tuo amministratore non ha aggiunto alcun plugin.", + "empty_uploads": "Installa un plugin da un file ZIP per iniziare.", + "empty_demo": "I plugin richiedono un account reale, quindi la demo non ne ha.", + "syncing": "Verifica dei plugin con il server…", + "status_installed": "Disattivato", + "status_enabled": "Avvio in corso", + "status_running": "Attivo", + "status_disabled": "Disattivato", + "status_error": "Problema", + "badge_forced": "Obbligatorio", + "badge_managed": "Dal tuo amministratore", + "badge_yours": "Installato da te", + "badge_awaiting": "In attesa di approvazione", + "toggle_label": "Attiva o disattiva {name}", + "forced_note": "Il tuo amministratore richiede questo plugin, quindi non può essere disattivato.", + "permissions": "Cosa può fare", + "permissions_managed": "Cosa può fare (approvato dal tuo amministratore)", + "settings": "Impostazioni", + "remove": "Rimuovi", + "remove_title": "Rimuovere {name}?", + "remove_message": "Il plugin e tutto ciò che ha salvato in questo browser verranno eliminati. Potrai installarlo di nuovo in seguito.", + "remove_confirm": "Rimuovi", + "error_user_plugins_off": "Il tuo amministratore ha disattivato i plugin installati dagli utenti, quindi questo plugin non viene eseguito.", + "error_approval_unavailable": "Questo plugin è stato installato prima che fosse richiesta l'approvazione. Rimuovilo e installalo di nuovo per inviarlo in approvazione.", + "error_approval_denied": "Il tuo amministratore non ha approvato questo plugin.", + "error_approval_pending": "In attesa che il tuo amministratore approvi questo plugin." + }, + "consent": { + "title": "Consentire a {name} di fare questo?", + "intro": "Il plugin chiede le autorizzazioni qui sotto. Le ottiene solo se le concedi.", + "high_risk_note": "Le voci contrassegnate danno al plugin accesso al contenuto della tua posta o gli permettono di inviare dati altrove. Consentile solo ai plugin di cui ti fidi.", + "revoke_hint": "Puoi disattivare o rimuovere il plugin in Impostazioni in qualsiasi momento.", + "deny": "Non consentire", + "allow": "Consenti" + }, + "permissions": { + "none": "Non richiede autorizzazioni particolari.", + "high_risk": "Con cautela", + "email_read": { + "title": "Leggere la tua posta", + "body": "Vedere mittenti, destinatari, oggetti e il testo completo dei messaggi che apri e dell'elenco dei messaggi." + }, + "email_write": { + "title": "Modificare etichette e contrassegni della tua posta", + "body": "Aggiungere o rimuovere etichette, contrassegni e altre parole chiave dai tuoi messaggi." + }, + "email_send": { + "title": "Modificare la posta prima dell'invio", + "body": "Leggere e modificare destinatari, oggetto, testo e allegati dei messaggi che scrivi, e impedire l'invio di un messaggio." + }, + "crypto_full": { + "title": "Accesso completo a questa app", + "body": "Funziona con gli stessi diritti della webmail stessa. Può leggere tutta la tua posta e le chiavi salvate e agire per conto tuo. Consentilo solo ai plugin di cui ti fidi completamente." + }, + "email_raw_send": { + "title": "Inviare messaggi creati da sé", + "body": "Inviare messaggi completi, ad esempio firmati o cifrati, dal tuo account." + }, + "email_blob_read": { + "title": "Leggere il contenuto grezzo dei messaggi", + "body": "Leggere i dati originali dei tuoi messaggi e allegati, e di ogni file che alleghi prima dell'invio." + }, + "email_blob_write": { + "title": "Sostituire gli allegati prima dell'invio", + "body": "Caricare file sul tuo server di posta e sostituire un file allegato con un altro prima che il messaggio venga inviato." + }, + "email_render_takeover": { + "title": "Modificare come vengono mostrati i messaggi", + "body": "Sostituire il testo dei messaggi che apri e dell'elenco dei messaggi, ad esempio per mostrare contenuti decifrati." + }, + "calendar_read": { + "title": "Leggere il tuo calendario", + "body": "Vedere i tuoi calendari e i relativi eventi." + }, + "calendar_write": { + "title": "Modificare il tuo calendario", + "body": "Creare, modificare ed eliminare eventi." + }, + "contacts_read": { + "title": "Leggere i tuoi contatti", + "body": "Cercare e leggere nelle tue rubriche." + }, + "contacts_write": { + "title": "Modificare i tuoi contatti", + "body": "Creare, modificare ed eliminare contatti e rubriche." + }, + "files_read": { + "title": "Leggere i tuoi file", + "body": "Sfogliare e aprire i file salvati nel tuo account." + }, + "files_write": { + "title": "Modificare i tuoi file", + "body": "Caricare, rinominare, spostare ed eliminare file nel tuo account." + }, + "identity_read": { + "title": "Vedere i tuoi indirizzi mittente", + "body": "Vedere gli indirizzi da cui invii e le relative firme." + }, + "identity_write": { + "title": "Modificare i tuoi indirizzi mittente", + "body": "Creare, modificare ed eliminare indirizzi mittente e firme." + }, + "filters_read": { + "title": "Leggere i tuoi filtri di posta", + "body": "Vedere le regole che ordinano la posta in arrivo." + }, + "filters_write": { + "title": "Modificare i tuoi filtri di posta", + "body": "Aggiungere regole ai tuoi filtri sul server. Queste regole possono spostare, inoltrare o eliminare la posta in arrivo." + }, + "tasks_read": { + "title": "Leggere le tue attività", + "body": "Vedere i tuoi elenchi di attività e le attività." + }, + "tasks_write": { + "title": "Modificare le tue attività", + "body": "Creare, modificare, completare ed eliminare attività." + }, + "templates_read": { + "title": "Leggere i tuoi modelli", + "body": "Vedere i tuoi modelli di messaggio salvati." + }, + "templates_write": { + "title": "Modificare i tuoi modelli", + "body": "Creare, modificare ed eliminare modelli di messaggio." + }, + "smime_read": { + "title": "Vedere il tuo stato S/MIME", + "body": "Vedere se hai chiavi e certificati S/MIME, senza accesso alle chiavi." + }, + "vacation_read": { + "title": "Vedere la tua risposta automatica", + "body": "Vedere se la risposta automatica è attiva e cosa dice." + }, + "vacation_write": { + "title": "Modificare la tua risposta automatica", + "body": "Attivare o disattivare la risposta automatica e modificarne testo e date." + }, + "settings_read": { + "title": "Leggere le tue impostazioni", + "body": "Vedere le tue preferenze e le definizioni delle etichette." + }, + "settings_write": { + "title": "Modificare le tue impostazioni", + "body": "Aggiungere e riordinare etichette e modificare le tue preferenze." + }, + "security_read": { + "title": "Vedere la sicurezza del tuo account", + "body": "Vedere se l'accesso a due fattori e la cifratura sono attivi, senza accesso ai segreti." + }, + "auth_observe": { + "title": "Sapere quando accedi", + "body": "Ricevere un avviso quando accedi, cambi account o la tua sessione viene rinnovata." + }, + "auth_emit": { + "title": "Disconnetterti", + "body": "Disconnetterti dalla webmail." + }, + "account_read": { + "title": "Vedere i tuoi account", + "body": "Vedere gli account che hai aggiunto, con i relativi indirizzi e server." + }, + "http_post": { + "title": "Usare il server della webmail per te", + "body": "Chiamare questi endpoint del server con il tuo accesso:" + }, + "http_fetch": { + "title": "Connettersi ad altri siti web", + "body": "Inviare richieste a questi siti. Tutto ciò che il plugin può leggere può essere inviato lì:" + }, + "admin_config": { + "title": "Leggere le sue impostazioni dal tuo amministratore", + "body": "Leggere le impostazioni che il tuo amministratore ha salvato per questo plugin." + }, + "ui_observe": { + "title": "Seguire dove ti trovi nell'app", + "body": "Sapere quale pagina è aperta e reagire ai link su cui fai clic." + }, + "ui_toolbar": { + "title": "Aggiungere pulsanti alla barra degli strumenti del messaggio", + "body": "Mostrare i propri pulsanti sopra un messaggio aperto." + }, + "ui_app_top_banner": { + "title": "Mostrare un banner in alto", + "body": "Mostrare un avviso lungo tutta la parte superiore dell'app." + }, + "ui_email_banner": { + "title": "Mostrare avvisi sui messaggi", + "body": "Mostrare un avviso sopra il testo di un messaggio aperto." + }, + "ui_email_footer": { + "title": "Aggiungere contenuti sotto i messaggi", + "body": "Mostrare i propri contenuti sotto un messaggio aperto." + }, + "ui_contact_cryptokeys": { + "title": "Mostrare le chiavi di cifratura dei contatti", + "body": "Mostrare e gestire le chiavi di cifratura di un contatto." + }, + "ui_email_details": { + "title": "Aggiungere pannelli ai dettagli del messaggio", + "body": "Mostrare un proprio pannello accanto o sotto un messaggio aperto." + }, + "ui_download_file": { + "title": "Salvare file sul tuo dispositivo", + "body": "Offrire in download i file che ha creato." + }, + "ui_message_list_tabs": { + "title": "Aggiungere schede alla tua posta in arrivo", + "body": "Suddividere la posta in arrivo in schede come Aggiornamenti o Promozioni." + }, + "ui_composer_toolbar": { + "title": "Aggiungere pulsanti all'editor", + "body": "Mostrare i propri pulsanti mentre scrivi un messaggio." + }, + "ui_composer_sidebar": { + "title": "Aggiungere un pannello all'editor", + "body": "Mostrare un proprio pannello accanto al messaggio che scrivi." + }, + "ui_sidebar_widget": { + "title": "Aggiungere un riquadro alla barra laterale", + "body": "Mostrare i propri contenuti nella barra laterale della posta." + }, + "ui_settings_section": { + "title": "Aggiungere una pagina di impostazioni", + "body": "Mostrare una propria pagina in Impostazioni." + }, + "ui_context_menu": { + "title": "Aggiungere voci al menu del messaggio", + "body": "Aggiungere voci al menu che si apre quando fai clic destro su un messaggio." + }, + "ui_navigation_rail": { + "title": "Aggiungere alla barra di navigazione", + "body": "Mostrare un proprio pulsante nella barra di navigazione." + }, + "ui_keyboard": { + "title": "Aggiungere scorciatoie da tastiera", + "body": "Reagire alle scorciatoie da tastiera che definisce." + }, + "ui_calendar_action": { + "title": "Aggiungere pulsanti agli eventi", + "body": "Mostrare i propri pulsanti quando crei o modifichi un evento." + }, + "ui_admin_page": { + "title": "Aggiungere una pagina di amministrazione", + "body": "Mostrare una propria pagina nelle impostazioni di amministrazione." + }, + "app_lifecycle": { + "title": "Sapere quando l'app si avvia e si chiude", + "body": "Ricevere un avviso quando l'app si avvia, va offline o ti disconnetti." + } } }, "global_search": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 93fefc413..0450880d7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "認証に失敗しました: {error}", "missing_params": "認証コードまたはステートがありません。", "delivery_failed": "認証結果をアプリのタブに渡せませんでした。このウィンドウを閉じて、もう一度お試しください。" + }, + "settings": { + "title": "プラグイン", + "description": "管理者が追加したプラグインです。アカウントごとにオンとオフを切り替えられます。", + "description_uploads": "プラグインでウェブメールに機能を追加できます。アカウントごとにオンとオフを切り替えたり、自分でインストールしたりできます。", + "description_demo": "デモではプラグインを利用できません。", + "install_button": "プラグインをインストール", + "install_hint": "プラグインの ZIP ファイルを選択してください。", + "install_hint_approval": "プラグインの ZIP ファイルを選択してください。オンにする前に管理者の承認が必要です。", + "install_uploads_off": "管理者がプラグインのインストールを許可していません。", + "install_managed_id": "同じ名前のプラグインが管理者から提供されているため、置き換えられません。", + "install_invalid": "このファイルはインストールできるプラグインではありません: {details}", + "toast_installed": "プラグインをインストールしました。下でオンにしてください。", + "toast_installed_pending": "プラグインをインストールし、管理者に承認を依頼しました。", + "toast_enabled": "{name} をオンにしました", + "toast_disabled": "{name} をオフにしました", + "toast_removed": "{name} を削除しました", + "toast_forced": "{name} は管理者によって常にオンになっています", + "toast_approval_requested": "{name} は管理者の承認を待っています", + "empty_title": "プラグインはまだありません", + "empty_admin": "管理者はまだプラグインを追加していません。", + "empty_uploads": "ZIP ファイルからプラグインをインストールして始めましょう。", + "empty_demo": "プラグインには実際のアカウントが必要なため、デモにはありません。", + "syncing": "サーバーとプラグインを確認しています…", + "status_installed": "オフ", + "status_enabled": "起動中", + "status_running": "オン", + "status_disabled": "オフ", + "status_error": "問題あり", + "badge_forced": "必須", + "badge_managed": "管理者から提供", + "badge_yours": "自分でインストール", + "badge_awaiting": "承認待ち", + "toggle_label": "{name} のオンとオフを切り替え", + "forced_note": "このプラグインは管理者によって必須とされているため、オフにできません。", + "permissions": "できること", + "permissions_managed": "できること(管理者が承認済み)", + "settings": "設定", + "remove": "削除", + "remove_title": "{name} を削除しますか?", + "remove_message": "プラグインと、このブラウザーに保存したデータがすべて削除されます。後でもう一度インストールできます。", + "remove_confirm": "削除", + "error_user_plugins_off": "管理者がユーザーによるプラグインを無効にしたため、このプラグインは動作しません。", + "error_approval_unavailable": "このプラグインは承認が必要になる前にインストールされました。承認を依頼するには、削除してもう一度インストールしてください。", + "error_approval_denied": "管理者はこのプラグインを承認しませんでした。", + "error_approval_pending": "管理者がこのプラグインを承認するのを待っています。" + }, + "consent": { + "title": "{name} にこの操作を許可しますか?", + "intro": "このプラグインは次の権限を求めています。許可した場合にのみ付与されます。", + "high_risk_note": "印の付いた項目は、プラグインにメールの内容へのアクセスや外部へのデータ送信を許可します。信頼できるプラグインにのみ許可してください。", + "revoke_hint": "プラグインはいつでも設定でオフにしたり削除したりできます。", + "deny": "許可しない", + "allow": "許可" + }, + "permissions": { + "none": "特別な権限は必要ありません。", + "high_risk": "注意が必要", + "email_read": { + "title": "メールを読む", + "body": "開いたメッセージとメッセージ一覧の差出人、宛先、件名、本文全体を見ることができます。" + }, + "email_write": { + "title": "メールのタグとフラグを変更", + "body": "メッセージにタグ、フラグ、その他のキーワードを追加したり削除したりできます。" + }, + "email_send": { + "title": "送信前のメールを変更", + "body": "作成中のメッセージの宛先、件名、本文、添付ファイルを読んで変更したり、メッセージの送信を止めたりできます。" + }, + "crypto_full": { + "title": "このアプリへのフルアクセス", + "body": "ウェブメール本体と同じ権限で動作します。すべてのメールと保存された鍵を読み、あなたに代わって操作できます。完全に信頼できるプラグインにのみ許可してください。" + }, + "email_raw_send": { + "title": "自分で作成したメッセージを送信", + "body": "署名済みや暗号化済みのメッセージなど、完成したメッセージをあなたのアカウントから送信できます。" + }, + "email_blob_read": { + "title": "メッセージの元データを読む", + "body": "メッセージと添付ファイルの元データ、および送信前に添付するすべてのファイルを読むことができます。" + }, + "email_blob_write": { + "title": "送信前に添付ファイルを置き換え", + "body": "メールサーバーにファイルをアップロードし、送信前に添付したファイルを別のファイルに置き換えることができます。" + }, + "email_render_takeover": { + "title": "メッセージの表示方法を変更", + "body": "開いたメッセージとメッセージ一覧の本文を置き換えることができます。たとえば復号した内容を表示するためです。" + }, + "calendar_read": { + "title": "カレンダーを読む", + "body": "カレンダーとその予定を見ることができます。" + }, + "calendar_write": { + "title": "カレンダーを変更", + "body": "予定を作成、変更、削除できます。" + }, + "contacts_read": { + "title": "連絡先を読む", + "body": "アドレス帳を検索して読むことができます。" + }, + "contacts_write": { + "title": "連絡先を変更", + "body": "連絡先とアドレス帳を作成、変更、削除できます。" + }, + "files_read": { + "title": "ファイルを読む", + "body": "アカウントに保存されたファイルを閲覧して開くことができます。" + }, + "files_write": { + "title": "ファイルを変更", + "body": "アカウント内のファイルをアップロード、名前変更、移動、削除できます。" + }, + "identity_read": { + "title": "送信者情報を見る", + "body": "送信に使うアドレスとその署名を見ることができます。" + }, + "identity_write": { + "title": "送信者情報を変更", + "body": "送信者情報と署名を作成、変更、削除できます。" + }, + "filters_read": { + "title": "メールフィルターを読む", + "body": "受信メールを振り分けるルールを見ることができます。" + }, + "filters_write": { + "title": "メールフィルターを変更", + "body": "サーバー側のフィルターにルールを追加できます。このルールは受信メールを移動、転送、削除できます。" + }, + "tasks_read": { + "title": "タスクを読む", + "body": "タスクリストとタスクを見ることができます。" + }, + "tasks_write": { + "title": "タスクを変更", + "body": "タスクを作成、変更、完了、削除できます。" + }, + "templates_read": { + "title": "テンプレートを読む", + "body": "保存したメッセージのテンプレートを見ることができます。" + }, + "templates_write": { + "title": "テンプレートを変更", + "body": "メッセージのテンプレートを作成、変更、削除できます。" + }, + "smime_read": { + "title": "S/MIME の状態を見る", + "body": "S/MIME の鍵と証明書があるかどうかを見ることができます。鍵にはアクセスできません。" + }, + "vacation_read": { + "title": "不在時の自動返信を見る", + "body": "自動返信がオンかどうかと、その内容を見ることができます。" + }, + "vacation_write": { + "title": "不在時の自動返信を変更", + "body": "自動返信のオンとオフを切り替え、本文と期間を変更できます。" + }, + "settings_read": { + "title": "設定を読む", + "body": "環境設定とタグの定義を見ることができます。" + }, + "settings_write": { + "title": "設定を変更", + "body": "タグを追加して並べ替えたり、環境設定を変更したりできます。" + }, + "security_read": { + "title": "アカウントのセキュリティを見る", + "body": "二要素認証と暗号化がオンかどうかを見ることができます。秘密情報にはアクセスできません。" + }, + "auth_observe": { + "title": "サインインを知る", + "body": "サインインしたとき、アカウントを切り替えたとき、セッションが更新されたときに通知を受けます。" + }, + "auth_emit": { + "title": "サインアウトさせる", + "body": "ウェブメールからサインアウトさせることができます。" + }, + "account_read": { + "title": "アカウントを見る", + "body": "追加したアカウントと、そのアドレスとサーバーを見ることができます。" + }, + "http_post": { + "title": "あなたの代わりにウェブメールサーバーを使う", + "body": "あなたのログイン情報で次のサーバーエンドポイントを呼び出せます:" + }, + "http_fetch": { + "title": "他のウェブサイトに接続", + "body": "次のサイトにリクエストを送信できます。プラグインが読めるものはすべて送信される可能性があります:" + }, + "admin_config": { + "title": "管理者が保存した設定を読む", + "body": "管理者がこのプラグイン用に保存した設定を読むことができます。" + }, + "ui_observe": { + "title": "アプリ内の現在地を把握", + "body": "どのページが開いているかを知り、クリックしたリンクに反応できます。" + }, + "ui_toolbar": { + "title": "メッセージのツールバーにボタンを追加", + "body": "開いたメッセージの上に独自のボタンを表示します。" + }, + "ui_app_top_banner": { + "title": "上部にバナーを表示", + "body": "アプリの上部にお知らせを表示します。" + }, + "ui_email_banner": { + "title": "メッセージにお知らせを表示", + "body": "開いたメッセージの本文の上にお知らせを表示します。" + }, + "ui_email_footer": { + "title": "メッセージの下に内容を追加", + "body": "開いたメッセージの下に独自の内容を表示します。" + }, + "ui_contact_cryptokeys": { + "title": "連絡先の暗号鍵を表示", + "body": "連絡先の暗号鍵を表示して管理します。" + }, + "ui_email_details": { + "title": "メッセージの詳細にパネルを追加", + "body": "開いたメッセージの横または下に独自のパネルを表示します。" + }, + "ui_download_file": { + "title": "デバイスにファイルを保存", + "body": "作成したファイルをダウンロードできるようにします。" + }, + "ui_message_list_tabs": { + "title": "受信トレイにタブを追加", + "body": "受信トレイを「更新」や「プロモーション」などのタブに分けます。" + }, + "ui_composer_toolbar": { + "title": "作成画面にボタンを追加", + "body": "メッセージの作成中に独自のボタンを表示します。" + }, + "ui_composer_sidebar": { + "title": "作成画面にパネルを追加", + "body": "作成中のメッセージの横に独自のパネルを表示します。" + }, + "ui_sidebar_widget": { + "title": "サイドバーにボックスを追加", + "body": "メールのサイドバーに独自の内容を表示します。" + }, + "ui_settings_section": { + "title": "設定ページを追加", + "body": "設定に独自のページを表示します。" + }, + "ui_context_menu": { + "title": "メッセージのメニューに追加", + "body": "メッセージを右クリックしたときに開くメニューに項目を追加します。" + }, + "ui_navigation_rail": { + "title": "ナビゲーションバーに追加", + "body": "ナビゲーションバーに独自のボタンを表示します。" + }, + "ui_keyboard": { + "title": "キーボードショートカットを追加", + "body": "独自に定義したキーボードショートカットに反応します。" + }, + "ui_calendar_action": { + "title": "予定にボタンを追加", + "body": "予定の作成時や編集時に独自のボタンを表示します。" + }, + "ui_admin_page": { + "title": "管理ページを追加", + "body": "管理設定に独自のページを表示します。" + }, + "app_lifecycle": { + "title": "アプリの起動と終了を知る", + "body": "アプリが起動したとき、オフラインになったとき、サインアウトしたときに通知を受けます。" + } } }, "global_search": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 32ab2e3f9..9dc945c01 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "인증 실패: {error}", "missing_params": "인증 코드 또는 상태가 누락되었습니다.", "delivery_failed": "인증 결과를 앱 탭에 전달하지 못했습니다. 이 창을 닫고 다시 시도해 주세요." + }, + "settings": { + "title": "플러그인", + "description": "관리자가 추가한 플러그인이에요. 내 계정에서 켜거나 끌 수 있어요.", + "description_uploads": "플러그인으로 웹메일에 기능을 더할 수 있어요. 내 계정에서 켜거나 끄고, 직접 설치할 수도 있어요.", + "description_demo": "데모에서는 플러그인을 사용할 수 없어요.", + "install_button": "플러그인 설치", + "install_hint": "플러그인 ZIP 파일을 선택하세요.", + "install_hint_approval": "플러그인 ZIP 파일을 선택하세요. 켜기 전에 관리자의 승인이 필요해요.", + "install_uploads_off": "관리자가 플러그인 설치를 허용하지 않아요.", + "install_managed_id": "관리자가 같은 이름의 플러그인을 이미 제공하고 있어 바꿀 수 없어요.", + "install_invalid": "이 파일은 설치할 수 있는 플러그인이 아니에요: {details}", + "toast_installed": "플러그인을 설치했어요. 아래에서 켜세요.", + "toast_installed_pending": "플러그인을 설치하고 관리자에게 승인을 요청했어요.", + "toast_enabled": "{name} 켜짐", + "toast_disabled": "{name} 꺼짐", + "toast_removed": "{name}을(를) 삭제했어요", + "toast_forced": "관리자가 {name}을(를) 항상 켜 두도록 설정했어요", + "toast_approval_requested": "{name}이(가) 관리자의 승인을 기다리고 있어요", + "empty_title": "아직 플러그인이 없어요", + "empty_admin": "관리자가 추가한 플러그인이 없어요.", + "empty_uploads": "ZIP 파일에서 플러그인을 설치해 시작해 보세요.", + "empty_demo": "플러그인은 실제 계정이 필요해서 데모에는 없어요.", + "syncing": "서버에서 플러그인을 확인하는 중…", + "status_installed": "꺼짐", + "status_enabled": "시작 중", + "status_running": "켜짐", + "status_disabled": "꺼짐", + "status_error": "문제 있음", + "badge_forced": "필수", + "badge_managed": "관리자 제공", + "badge_yours": "내가 설치함", + "badge_awaiting": "승인 대기 중", + "toggle_label": "{name} 켜기 또는 끄기", + "forced_note": "관리자가 이 플러그인을 필수로 지정해서 끌 수 없어요.", + "permissions": "할 수 있는 작업", + "permissions_managed": "할 수 있는 작업 (관리자가 승인함)", + "settings": "설정", + "remove": "삭제", + "remove_title": "{name}을(를) 삭제할까요?", + "remove_message": "플러그인과 플러그인이 이 브라우저에 저장한 모든 데이터가 삭제돼요. 나중에 다시 설치할 수 있어요.", + "remove_confirm": "삭제", + "error_user_plugins_off": "관리자가 사용자가 설치한 플러그인을 꺼 두어서 이 플러그인은 실행되지 않아요.", + "error_approval_unavailable": "이 플러그인은 승인이 필요해지기 전에 설치되었어요. 승인을 요청하려면 삭제한 뒤 다시 설치하세요.", + "error_approval_denied": "관리자가 이 플러그인을 승인하지 않았어요.", + "error_approval_pending": "관리자가 이 플러그인을 승인하기를 기다리고 있어요." + }, + "consent": { + "title": "{name}에 이 권한을 허용할까요?", + "intro": "플러그인이 아래 권한을 요청해요. 허용한 경우에만 권한이 주어져요.", + "high_risk_note": "표시된 항목은 플러그인이 메일 내용에 접근하거나 데이터를 외부로 보낼 수 있게 해요. 신뢰하는 플러그인에만 허용하세요.", + "revoke_hint": "언제든지 설정에서 플러그인을 끄거나 삭제할 수 있어요.", + "deny": "허용 안 함", + "allow": "허용" + }, + "permissions": { + "none": "특별한 권한이 필요하지 않아요.", + "high_risk": "주의 필요", + "email_read": { + "title": "메일 읽기", + "body": "열어 본 메시지와 메시지 목록의 보낸 사람, 받는 사람, 제목, 전체 본문을 볼 수 있어요." + }, + "email_write": { + "title": "메일의 태그와 플래그 변경", + "body": "메시지에 태그, 플래그 및 기타 키워드를 추가하거나 제거할 수 있어요." + }, + "email_send": { + "title": "보내기 전에 메일 변경", + "body": "작성 중인 메시지의 받는 사람, 제목, 본문, 첨부 파일을 읽고 바꾸거나, 메시지가 전송되지 않게 막을 수 있어요." + }, + "crypto_full": { + "title": "이 앱에 대한 전체 접근 권한", + "body": "웹메일과 같은 권한으로 실행돼요. 모든 메일과 저장된 키를 읽고 사용자를 대신해 작업할 수 있어요. 완전히 신뢰하는 플러그인에만 허용하세요." + }, + "email_raw_send": { + "title": "직접 만든 메시지 보내기", + "body": "서명되거나 암호화된 메시지 같은 완성된 메시지를 내 계정에서 보낼 수 있어요." + }, + "email_blob_read": { + "title": "메시지 원본 읽기", + "body": "메시지와 첨부 파일의 원본 데이터, 그리고 보내기 전에 첨부하는 모든 파일을 읽을 수 있어요." + }, + "email_blob_write": { + "title": "보내기 전에 첨부 파일 바꾸기", + "body": "메일 서버에 파일을 업로드하고, 메시지를 보내기 전에 첨부한 파일을 다른 파일로 바꿀 수 있어요." + }, + "email_render_takeover": { + "title": "메시지 표시 방식 변경", + "body": "열어 본 메시지와 메시지 목록의 본문을 바꿀 수 있어요. 예를 들어 복호화된 내용을 보여 줄 때 사용해요." + }, + "calendar_read": { + "title": "캘린더 읽기", + "body": "캘린더와 일정을 볼 수 있어요." + }, + "calendar_write": { + "title": "캘린더 변경", + "body": "일정을 만들고, 바꾸고, 삭제할 수 있어요." + }, + "contacts_read": { + "title": "연락처 읽기", + "body": "주소록을 검색하고 읽을 수 있어요." + }, + "contacts_write": { + "title": "연락처 변경", + "body": "연락처와 주소록을 만들고, 바꾸고, 삭제할 수 있어요." + }, + "files_read": { + "title": "파일 읽기", + "body": "계정에 저장된 파일을 둘러보고 열 수 있어요." + }, + "files_write": { + "title": "파일 변경", + "body": "계정의 파일을 업로드하고, 이름을 바꾸고, 이동하고, 삭제할 수 있어요." + }, + "identity_read": { + "title": "보내는 사람 주소 보기", + "body": "메일을 보낼 때 쓰는 주소와 서명을 볼 수 있어요." + }, + "identity_write": { + "title": "보내는 사람 주소 변경", + "body": "보내는 사람 주소와 서명을 만들고, 바꾸고, 삭제할 수 있어요." + }, + "filters_read": { + "title": "메일 필터 읽기", + "body": "받은 메일을 분류하는 규칙을 볼 수 있어요." + }, + "filters_write": { + "title": "메일 필터 변경", + "body": "서버 측 필터에 규칙을 추가할 수 있어요. 이런 규칙은 받은 메일을 이동, 전달하거나 삭제할 수 있어요." + }, + "tasks_read": { + "title": "할 일 읽기", + "body": "할 일 목록과 할 일을 볼 수 있어요." + }, + "tasks_write": { + "title": "할 일 변경", + "body": "할 일을 만들고, 바꾸고, 완료하고, 삭제할 수 있어요." + }, + "templates_read": { + "title": "템플릿 읽기", + "body": "저장된 메시지 템플릿을 볼 수 있어요." + }, + "templates_write": { + "title": "템플릿 변경", + "body": "메시지 템플릿을 만들고, 바꾸고, 삭제할 수 있어요." + }, + "smime_read": { + "title": "S/MIME 상태 보기", + "body": "S/MIME 키와 인증서가 있는지 볼 수 있어요. 키에는 접근할 수 없어요." + }, + "vacation_read": { + "title": "부재중 자동 응답 보기", + "body": "자동 응답이 켜져 있는지와 그 내용을 볼 수 있어요." + }, + "vacation_write": { + "title": "부재중 자동 응답 변경", + "body": "자동 응답을 켜거나 끄고, 내용과 날짜를 바꿀 수 있어요." + }, + "settings_read": { + "title": "설정 읽기", + "body": "환경설정과 태그 정의를 볼 수 있어요." + }, + "settings_write": { + "title": "설정 변경", + "body": "태그를 추가하고 순서를 바꾸며, 환경설정을 변경할 수 있어요." + }, + "security_read": { + "title": "계정 보안 보기", + "body": "2단계 인증과 암호화가 켜져 있는지 볼 수 있어요. 비밀 정보에는 접근할 수 없어요." + }, + "auth_observe": { + "title": "로그인 알림 받기", + "body": "로그인하거나, 계정을 전환하거나, 세션이 갱신될 때 알림을 받아요." + }, + "auth_emit": { + "title": "로그아웃시키기", + "body": "웹메일에서 로그아웃시킬 수 있어요." + }, + "account_read": { + "title": "계정 보기", + "body": "추가한 계정과 그 주소 및 서버를 볼 수 있어요." + }, + "http_post": { + "title": "나 대신 웹메일 서버 사용", + "body": "내 로그인 정보로 다음 서버 엔드포인트를 호출할 수 있어요:" + }, + "http_fetch": { + "title": "다른 웹사이트에 연결", + "body": "다음 사이트에 요청을 보낼 수 있어요. 플러그인이 읽을 수 있는 모든 내용이 그곳으로 전송될 수 있어요:" + }, + "admin_config": { + "title": "관리자가 저장한 설정 읽기", + "body": "관리자가 이 플러그인용으로 저장한 설정을 읽을 수 있어요." + }, + "ui_observe": { + "title": "앱에서 현재 위치 파악", + "body": "어떤 페이지가 열려 있는지 알고, 클릭한 링크에 반응할 수 있어요." + }, + "ui_toolbar": { + "title": "메시지 도구 모음에 버튼 추가", + "body": "열어 본 메시지 위에 자체 버튼을 표시해요." + }, + "ui_app_top_banner": { + "title": "상단에 배너 표시", + "body": "앱 상단에 안내를 표시해요." + }, + "ui_email_banner": { + "title": "메시지에 안내 표시", + "body": "열어 본 메시지 본문 위에 안내를 표시해요." + }, + "ui_email_footer": { + "title": "메시지 아래에 내용 추가", + "body": "열어 본 메시지 아래에 자체 내용을 표시해요." + }, + "ui_contact_cryptokeys": { + "title": "연락처의 암호화 키 표시", + "body": "연락처의 암호화 키를 표시하고 관리해요." + }, + "ui_email_details": { + "title": "메시지 세부 정보에 패널 추가", + "body": "열어 본 메시지 옆이나 아래에 자체 패널을 표시해요." + }, + "ui_download_file": { + "title": "기기에 파일 저장", + "body": "직접 만든 파일을 다운로드할 수 있게 제공해요." + }, + "ui_message_list_tabs": { + "title": "받은편지함에 탭 추가", + "body": "받은편지함을 업데이트나 프로모션 같은 탭으로 나눠요." + }, + "ui_composer_toolbar": { + "title": "작성 창에 버튼 추가", + "body": "메시지를 작성하는 동안 자체 버튼을 표시해요." + }, + "ui_composer_sidebar": { + "title": "작성 창에 패널 추가", + "body": "작성 중인 메시지 옆에 자체 패널을 표시해요." + }, + "ui_sidebar_widget": { + "title": "사이드바에 상자 추가", + "body": "메일 사이드바에 자체 내용을 표시해요." + }, + "ui_settings_section": { + "title": "설정 페이지 추가", + "body": "설정에 자체 페이지를 표시해요." + }, + "ui_context_menu": { + "title": "메시지 메뉴에 추가", + "body": "메시지를 마우스 오른쪽 버튼으로 클릭하면 열리는 메뉴에 항목을 추가해요." + }, + "ui_navigation_rail": { + "title": "내비게이션 바에 추가", + "body": "내비게이션 바에 자체 버튼을 표시해요." + }, + "ui_keyboard": { + "title": "단축키 추가", + "body": "직접 정의한 단축키에 반응해요." + }, + "ui_calendar_action": { + "title": "일정에 버튼 추가", + "body": "일정을 만들거나 편집할 때 자체 버튼을 표시해요." + }, + "ui_admin_page": { + "title": "관리자 페이지 추가", + "body": "관리자 설정에 자체 페이지를 표시해요." + }, + "app_lifecycle": { + "title": "앱 시작과 종료 알림 받기", + "body": "앱이 시작되거나, 오프라인이 되거나, 로그아웃할 때 알림을 받아요." + } } }, "global_search": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 894999162..87288d927 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorizācija neizdevās: {error}", "missing_params": "Trūkst autorizācijas kods vai stāvoklis.", "delivery_failed": "Autorizācijas rezultātu neizdevās nodot lietotnes cilnei. Aizveriet šo logu un mēģiniet vēlreiz." + }, + "settings": { + "title": "Spraudņi", + "description": "Spraudņi, ko pievienojis jūsu administrators. Ieslēdziet vai izslēdziet tos savam kontam.", + "description_uploads": "Spraudņi papildina webmail ar jaunām funkcijām. Ieslēdziet vai izslēdziet tos savam kontam vai instalējiet savus.", + "description_demo": "Demo režīmā spraudņi nav pieejami.", + "install_button": "Instalēt spraudni", + "install_hint": "Izvēlieties spraudņa ZIP failu.", + "install_hint_approval": "Izvēlieties spraudņa ZIP failu. Pirms varat to ieslēgt, tas jāapstiprina administratoram.", + "install_uploads_off": "Jūsu administrators neļauj instalēt spraudņus.", + "install_managed_id": "Jūsu administrators jau nodrošina spraudni ar šādu nosaukumu, tāpēc to nevar aizstāt.", + "install_invalid": "Šis fails nav instalējams spraudnis: {details}", + "toast_installed": "Spraudnis instalēts. Ieslēdziet to zemāk.", + "toast_installed_pending": "Spraudnis instalēts un nosūtīts jūsu administratoram apstiprināšanai.", + "toast_enabled": "Spraudnis {name} ir ieslēgts", + "toast_disabled": "Spraudnis {name} ir izslēgts", + "toast_removed": "Spraudnis {name} ir noņemts", + "toast_forced": "Jūsu administrators patur spraudni {name} ieslēgtu", + "toast_approval_requested": "Spraudnis {name} gaida jūsu administratora apstiprinājumu", + "empty_title": "Vēl nav spraudņu", + "empty_admin": "Jūsu administrators nav pievienojis nevienu spraudni.", + "empty_uploads": "Lai sāktu, instalējiet spraudni no ZIP faila.", + "empty_demo": "Spraudņiem vajadzīgs īsts konts, tāpēc demo režīmā to nav.", + "syncing": "Pārbauda spraudņus serverī…", + "status_installed": "Izslēgts", + "status_enabled": "Tiek palaists", + "status_running": "Ieslēgts", + "status_disabled": "Izslēgts", + "status_error": "Problēma", + "badge_forced": "Obligāts", + "badge_managed": "No jūsu administratora", + "badge_yours": "Instalējāt jūs", + "badge_awaiting": "Gaida apstiprinājumu", + "toggle_label": "Ieslēgt vai izslēgt spraudni {name}", + "forced_note": "Jūsu administrators pieprasa šo spraudni, tāpēc to nevar izslēgt.", + "permissions": "Ko tas var darīt", + "permissions_managed": "Ko tas var darīt (apstiprinājis jūsu administrators)", + "settings": "Iestatījumi", + "remove": "Noņemt", + "remove_title": "Noņemt spraudni {name}?", + "remove_message": "Spraudnis un viss, ko tas saglabājis šajā pārlūkā, tiks dzēsts. Vēlāk to var instalēt atkal.", + "remove_confirm": "Noņemt", + "error_user_plugins_off": "Jūsu administrators ir izslēdzis lietotāju instalētos spraudņus, tāpēc šis spraudnis nedarbojas.", + "error_approval_unavailable": "Šis spraudnis tika instalēts, pirms bija vajadzīgs apstiprinājums. Noņemiet to un instalējiet vēlreiz, lai nosūtītu apstiprināšanai.", + "error_approval_denied": "Jūsu administrators neapstiprināja šo spraudni.", + "error_approval_pending": "Gaida, kad jūsu administrators apstiprinās šo spraudni." + }, + "consent": { + "title": "Atļaut spraudnim {name} to darīt?", + "intro": "Spraudnis pieprasa tālāk norādītās atļaujas. Tas tās saņems tikai tad, ja jūs atļausiet.", + "high_risk_note": "Atzīmētie punkti dod spraudnim piekļuvi jūsu pasta saturam vai ļauj tam sūtīt datus citur. Atļaujiet tos tikai spraudņiem, kuriem uzticaties.", + "revoke_hint": "Spraudni jebkurā laikā varat izslēgt vai noņemt iestatījumos.", + "deny": "Neatļaut", + "allow": "Atļaut" + }, + "permissions": { + "none": "Tam nav vajadzīgas īpašas atļaujas.", + "high_risk": "Nepieciešama piesardzība", + "email_read": { + "title": "Lasīt jūsu pastu", + "body": "Redzēt sūtītājus, adresātus, tēmas un visu tekstu vēstulēm, ko atverat, un vēstulēm sarakstā." + }, + "email_write": { + "title": "Mainīt vēstuļu tagus un karodziņus", + "body": "Pievienot vai noņemt tagus, karodziņus un citus atslēgvārdus jūsu vēstulēm." + }, + "email_send": { + "title": "Mainīt vēstules pirms nosūtīšanas", + "body": "Lasīt un mainīt adresātus, tēmu, tekstu un pielikumus vēstulēm, ko rakstāt, un apturēt vēstules nosūtīšanu." + }, + "crypto_full": { + "title": "Pilna piekļuve lietotnei", + "body": "Darbojas ar tādām pašām tiesībām kā pats webmail. Var lasīt visu jūsu pastu un saglabātās atslēgas un rīkoties jūsu vārdā. Atļaujiet to tikai spraudņiem, kuriem pilnībā uzticaties." + }, + "email_raw_send": { + "title": "Sūtīt paša izveidotas vēstules", + "body": "Sūtīt no jūsu konta pilnīgas vēstules, piemēram, parakstītas vai šifrētas." + }, + "email_blob_read": { + "title": "Lasīt vēstuļu neapstrādātos datus", + "body": "Lasīt jūsu vēstuļu un pielikumu sākotnējos datus, kā arī katru failu, ko pievienojat, pirms tas tiek nosūtīts." + }, + "email_blob_write": { + "title": "Aizstāt pielikumus pirms nosūtīšanas", + "body": "Augšupielādēt failus jūsu pasta serverī un aizstāt pievienoto failu ar citu, pirms vēstule tiek nosūtīta." + }, + "email_render_takeover": { + "title": "Mainīt vēstuļu attēlojumu", + "body": "Aizstāt tekstu vēstulēm, ko atverat, un vēstulēm sarakstā, piemēram, lai parādītu atšifrētu saturu." + }, + "calendar_read": { + "title": "Lasīt jūsu kalendāru", + "body": "Redzēt jūsu kalendārus un to notikumus." + }, + "calendar_write": { + "title": "Mainīt jūsu kalendāru", + "body": "Izveidot, mainīt un dzēst notikumus." + }, + "contacts_read": { + "title": "Lasīt jūsu kontaktus", + "body": "Meklēt un lasīt jūsu adrešu grāmatās." + }, + "contacts_write": { + "title": "Mainīt jūsu kontaktus", + "body": "Izveidot, mainīt un dzēst kontaktus un adrešu grāmatas." + }, + "files_read": { + "title": "Lasīt jūsu failus", + "body": "Pārlūkot un atvērt jūsu kontā glabātos failus." + }, + "files_write": { + "title": "Mainīt jūsu failus", + "body": "Augšupielādēt, pārdēvēt, pārvietot un dzēst failus jūsu kontā." + }, + "identity_read": { + "title": "Redzēt jūsu sūtītāja adreses", + "body": "Redzēt adreses, no kurām sūtāt, un to parakstus." + }, + "identity_write": { + "title": "Mainīt jūsu sūtītāja adreses", + "body": "Izveidot, mainīt un dzēst sūtītāja adreses un parakstus." + }, + "filters_read": { + "title": "Lasīt jūsu pasta filtrus", + "body": "Redzēt noteikumus, kas šķiro jūsu ienākošo pastu." + }, + "filters_write": { + "title": "Mainīt jūsu pasta filtrus", + "body": "Pievienot noteikumus jūsu servera filtriem. Šādi noteikumi var pārvietot, pārsūtīt vai dzēst ienākošo pastu." + }, + "tasks_read": { + "title": "Lasīt jūsu uzdevumus", + "body": "Redzēt jūsu uzdevumu sarakstus un uzdevumus." + }, + "tasks_write": { + "title": "Mainīt jūsu uzdevumus", + "body": "Izveidot, mainīt, pabeigt un dzēst uzdevumus." + }, + "templates_read": { + "title": "Lasīt jūsu veidnes", + "body": "Redzēt jūsu saglabātās vēstuļu veidnes." + }, + "templates_write": { + "title": "Mainīt jūsu veidnes", + "body": "Izveidot, mainīt un dzēst vēstuļu veidnes." + }, + "smime_read": { + "title": "Redzēt jūsu S/MIME statusu", + "body": "Redzēt, vai jums ir S/MIME atslēgas un sertifikāti, bez piekļuves atslēgām." + }, + "vacation_read": { + "title": "Redzēt jūsu prombūtnes atbildi", + "body": "Redzēt, vai automātiskā atbilde ir ieslēgta un kas tajā rakstīts." + }, + "vacation_write": { + "title": "Mainīt jūsu prombūtnes atbildi", + "body": "Ieslēgt vai izslēgt automātisko atbildi un mainīt tās tekstu un datumus." + }, + "settings_read": { + "title": "Lasīt jūsu iestatījumus", + "body": "Redzēt jūsu preferences un tagu definīcijas." + }, + "settings_write": { + "title": "Mainīt jūsu iestatījumus", + "body": "Pievienot tagus, mainīt to secību un mainīt jūsu preferences." + }, + "security_read": { + "title": "Redzēt jūsu konta drošību", + "body": "Redzēt, vai divfaktoru autentifikācija un šifrēšana ir ieslēgtas, bez piekļuves noslēpumiem." + }, + "auth_observe": { + "title": "Zināt, kad pierakstāties", + "body": "Saņemt ziņu, kad pierakstāties, pārslēdzat kontu vai jūsu sesija tiek atjaunota." + }, + "auth_emit": { + "title": "Izrakstīt jūs", + "body": "Izrakstīt jūs no webmail." + }, + "account_read": { + "title": "Redzēt jūsu kontus", + "body": "Redzēt jūsu pievienotos kontus ar to adresēm un serveriem." + }, + "http_post": { + "title": "Izmantot webmail serveri jūsu vārdā", + "body": "Izsaukt šos servera galapunktus ar jūsu pieteikšanās datiem:" + }, + "http_fetch": { + "title": "Savienoties ar citām vietnēm", + "body": "Sūtīt pieprasījumus uz šīm vietnēm. Uz turieni var nosūtīt visu, ko spraudnis var nolasīt:" + }, + "admin_config": { + "title": "Lasīt savus iestatījumus no administratora", + "body": "Lasīt iestatījumus, ko jūsu administrators saglabājis šim spraudnim." + }, + "ui_observe": { + "title": "Sekot, kur atrodaties lietotnē", + "body": "Zināt, kura lapa ir atvērta, un reaģēt uz saitēm, uz kurām noklikšķināt." + }, + "ui_toolbar": { + "title": "Pievienot pogas vēstules rīkjoslai", + "body": "Rādīt savas pogas virs atvērtas vēstules." + }, + "ui_app_top_banner": { + "title": "Rādīt reklāmkarogu augšā", + "body": "Rādīt paziņojumu pāri lietotnes augšdaļai." + }, + "ui_email_banner": { + "title": "Rādīt paziņojumus vēstulēs", + "body": "Rādīt paziņojumu virs atvērtas vēstules teksta." + }, + "ui_email_footer": { + "title": "Pievienot saturu zem vēstulēm", + "body": "Rādīt savu saturu zem atvērtas vēstules." + }, + "ui_contact_cryptokeys": { + "title": "Rādīt kontaktu šifrēšanas atslēgas", + "body": "Rādīt un pārvaldīt kontakta šifrēšanas atslēgas." + }, + "ui_email_details": { + "title": "Pievienot paneļus vēstules informācijai", + "body": "Rādīt savu paneli blakus atvērtai vēstulei vai zem tās." + }, + "ui_download_file": { + "title": "Saglabāt failus jūsu ierīcē", + "body": "Piedāvāt lejupielādei paša izveidotus failus." + }, + "ui_message_list_tabs": { + "title": "Pievienot cilnes iesūtnei", + "body": "Sadalīt jūsu iesūtni cilnēs, piemēram, Jaunumi vai Akcijas." + }, + "ui_composer_toolbar": { + "title": "Pievienot pogas redaktoram", + "body": "Rādīt savas pogas, kamēr rakstāt vēstuli." + }, + "ui_composer_sidebar": { + "title": "Pievienot paneli redaktoram", + "body": "Rādīt savu paneli blakus vēstulei, ko rakstāt." + }, + "ui_sidebar_widget": { + "title": "Pievienot bloku sānjoslai", + "body": "Rādīt savu saturu pasta sānjoslā." + }, + "ui_settings_section": { + "title": "Pievienot iestatījumu lapu", + "body": "Rādīt savu lapu iestatījumos." + }, + "ui_context_menu": { + "title": "Papildināt vēstules izvēlni", + "body": "Pievienot ierakstus izvēlnei, kas atveras, ar peles labo pogu noklikšķinot uz vēstules." + }, + "ui_navigation_rail": { + "title": "Papildināt navigācijas joslu", + "body": "Rādīt savu pogu navigācijas joslā." + }, + "ui_keyboard": { + "title": "Pievienot īsinājumtaustiņus", + "body": "Reaģēt uz paša definētiem īsinājumtaustiņiem." + }, + "ui_calendar_action": { + "title": "Pievienot pogas notikumiem", + "body": "Rādīt savas pogas, kad izveidojat vai rediģējat notikumu." + }, + "ui_admin_page": { + "title": "Pievienot administrēšanas lapu", + "body": "Rādīt savu lapu administratora iestatījumos." + }, + "app_lifecycle": { + "title": "Zināt, kad lietotne startē un apstājas", + "body": "Saņemt ziņu, kad lietotne startē, pāriet bezsaistē vai jūs izrakstāties." + } } }, "global_search": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 93e8f670d..563431c54 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Зөвшөөрөл амжилтгүй болсон: {error}", "missing_params": "Зөвшөөрлийн код эсвэл төлөв байхгүй байна.", "delivery_failed": "Зөвшөөрлийн үр дүнг аппын таб руу дамжуулж чадсангүй. Энэ цонхыг хаагаад дахин оролдоно уу." + }, + "settings": { + "title": "Залгаасууд", + "description": "Таны администраторын нэмсэн залгаасууд. Өөрийн бүртгэлд асааж эсвэл унтрааж болно.", + "description_uploads": "Залгаасууд webmail-д шинэ боломж нэмдэг. Өөрийн бүртгэлд асааж, унтрааж эсвэл өөрийнхөө залгаасыг суулгаж болно.", + "description_demo": "Демо горимд залгаас ашиглах боломжгүй.", + "install_button": "Залгаас суулгах", + "install_hint": "Залгаасын ZIP файлыг сонгоно уу.", + "install_hint_approval": "Залгаасын ZIP файлыг сонгоно уу. Асаахаас өмнө администратор үүнийг зөвшөөрөх ёстой.", + "install_uploads_off": "Таны администратор залгаас суулгахыг зөвшөөрөөгүй байна.", + "install_managed_id": "Таны администратор ийм нэртэй залгаасыг аль хэдийн олгосон тул үүнийг солих боломжгүй.", + "install_invalid": "Энэ файл суулгаж болох залгаас биш байна: {details}", + "toast_installed": "Залгаас суулгалаа. Доороос асаана уу.", + "toast_installed_pending": "Залгаас суулгаж, зөвшөөрөл авахаар таны администратор руу илгээлээ.", + "toast_enabled": "{name} залгаас асаалттай", + "toast_disabled": "{name} залгаас унтарсан", + "toast_removed": "{name} залгаасыг устгалаа", + "toast_forced": "Таны администратор {name} залгаасыг асаалттай байлгахыг шаарддаг", + "toast_approval_requested": "{name} залгаас таны администраторын зөвшөөрлийг хүлээж байна", + "empty_title": "Одоогоор залгаас алга", + "empty_admin": "Таны администратор ямар ч залгаас нэмээгүй байна.", + "empty_uploads": "Эхлэхийн тулд ZIP файлаас залгаас суулгана уу.", + "empty_demo": "Залгаасуудад жинхэнэ бүртгэл хэрэгтэй тул демо горимд байхгүй.", + "syncing": "Залгаасуудыг сервертэй тулгаж байна…", + "status_installed": "Унтарсан", + "status_enabled": "Эхэлж байна", + "status_running": "Асаалттай", + "status_disabled": "Унтарсан", + "status_error": "Асуудалтай", + "badge_forced": "Заавал", + "badge_managed": "Таны администратороос", + "badge_yours": "Та суулгасан", + "badge_awaiting": "Зөвшөөрөл хүлээж байна", + "toggle_label": "{name} залгаасыг асаах эсвэл унтраах", + "forced_note": "Таны администратор энэ залгаасыг шаарддаг тул унтраах боломжгүй.", + "permissions": "Юу хийж чадах вэ", + "permissions_managed": "Юу хийж чадах вэ (таны администратор зөвшөөрсөн)", + "settings": "Тохиргоо", + "remove": "Устгах", + "remove_title": "{name} залгаасыг устгах уу?", + "remove_message": "Залгаас болон түүний энэ хөтөчид хадгалсан бүх зүйл устна. Дараа нь дахин суулгаж болно.", + "remove_confirm": "Устгах", + "error_user_plugins_off": "Таны администратор хэрэглэгчийн суулгасан залгаасуудыг унтраасан тул энэ залгаас ажиллахгүй.", + "error_approval_unavailable": "Энэ залгаасыг зөвшөөрөл шаарддаг болохоос өмнө суулгасан. Зөвшөөрөлд илгээхийн тулд устгаад дахин суулгана уу.", + "error_approval_denied": "Таны администратор энэ залгаасыг зөвшөөрөөгүй.", + "error_approval_pending": "Таны администратор энэ залгаасыг зөвшөөрөхийг хүлээж байна." + }, + "consent": { + "title": "{name} залгаасад үүнийг зөвшөөрөх үү?", + "intro": "Залгаас доорх зөвшөөрлүүдийг хүсэж байна. Та зөвшөөрсөн тохиолдолд л авна.", + "high_risk_note": "Тэмдэглэсэн зүйлс нь залгаасад таны имэйлийн агуулгыг харах эсвэл өгөгдлийг өөр газар илгээх боломж олгоно. Зөвхөн итгэдэг залгаасдаа зөвшөөрнө үү.", + "revoke_hint": "Та хүссэн үедээ Тохиргоо хэсэгт залгаасыг унтрааж эсвэл устгаж болно.", + "deny": "Зөвшөөрөхгүй", + "allow": "Зөвшөөрөх" + }, + "permissions": { + "none": "Тусгай зөвшөөрөл шаардлагагүй.", + "high_risk": "Болгоомжтой хандах", + "email_read": { + "title": "Таны имэйлийг унших", + "body": "Таны нээсэн имэйлүүд болон имэйлийн жагсаалтын илгээгч, хүлээн авагч, гарчиг, бүрэн текстийг харах." + }, + "email_write": { + "title": "Имэйлийн таг болон тугийг өөрчлөх", + "body": "Таны имэйлүүдэд таг, туг болон бусад түлхүүр үг нэмэх эсвэл хасах." + }, + "email_send": { + "title": "Илгээхээс өмнө имэйлийг өөрчлөх", + "body": "Таны бичиж буй имэйлийн хүлээн авагч, гарчиг, текст, хавсралтыг унших, өөрчлөх, мөн имэйл илгээхийг зогсоох." + }, + "crypto_full": { + "title": "Аппад бүрэн хандах", + "body": "Webmail-тэй адил эрхээр ажиллана. Таны бүх имэйл болон хадгалсан түлхүүрүүдийг уншиж, таны нэрийн өмнөөс үйлдэл хийж чадна. Зөвхөн бүрэн итгэдэг залгаасдаа үүнийг зөвшөөрнө үү." + }, + "email_raw_send": { + "title": "Өөрөө бэлтгэсэн имэйл илгээх", + "body": "Таны бүртгэлээс бүрэн бэлэн имэйл, жишээ нь гарын үсэг зурсан эсвэл шифрлэсэн имэйл илгээх." + }, + "email_blob_read": { + "title": "Имэйлийн түүхий агуулгыг унших", + "body": "Таны имэйл, хавсралтуудын эх өгөгдөл болон илгээхээс өмнө хавсаргасан файл бүрийг унших." + }, + "email_blob_write": { + "title": "Илгээхээс өмнө хавсралтыг солих", + "body": "Таны мэйл сервер рүү файл байршуулж, имэйл илгээгдэхээс өмнө хавсаргасан файлыг өөр файлаар солих." + }, + "email_render_takeover": { + "title": "Имэйлийг харуулах байдлыг өөрчлөх", + "body": "Таны нээсэн имэйлүүд болон имэйлийн жагсаалтын текстийг солих, жишээ нь тайлсан агуулгыг харуулах." + }, + "calendar_read": { + "title": "Таны календарийг унших", + "body": "Таны календарь болон тэдгээрийн үйл явдлуудыг харах." + }, + "calendar_write": { + "title": "Таны календарийг өөрчлөх", + "body": "Үйл явдал үүсгэх, өөрчлөх, устгах." + }, + "contacts_read": { + "title": "Таны харилцагчдыг унших", + "body": "Таны хаягийн дэвтрүүдээс хайх, унших." + }, + "contacts_write": { + "title": "Таны харилцагчдыг өөрчлөх", + "body": "Харилцагч болон хаягийн дэвтэр үүсгэх, өөрчлөх, устгах." + }, + "files_read": { + "title": "Таны файлуудыг унших", + "body": "Таны бүртгэлд хадгалсан файлуудыг үзэх, нээх." + }, + "files_write": { + "title": "Таны файлуудыг өөрчлөх", + "body": "Таны бүртгэл дэх файлуудыг байршуулах, нэрийг өөрчлөх, зөөх, устгах." + }, + "identity_read": { + "title": "Таны илгээгчийн хаягуудыг харах", + "body": "Таны илгээдэг хаягууд болон тэдгээрийн гарын үсгийг харах." + }, + "identity_write": { + "title": "Таны илгээгчийн хаягуудыг өөрчлөх", + "body": "Илгээгчийн хаяг болон гарын үсэг үүсгэх, өөрчлөх, устгах." + }, + "filters_read": { + "title": "Таны имэйл шүүлтүүрийг унших", + "body": "Ирж буй имэйлийг ангилдаг дүрмүүдийг харах." + }, + "filters_write": { + "title": "Таны имэйл шүүлтүүрийг өөрчлөх", + "body": "Сервер дээрх шүүлтүүрт дүрэм нэмэх. Ийм дүрэм ирж буй имэйлийг зөөх, дамжуулах эсвэл устгах боломжтой." + }, + "tasks_read": { + "title": "Таны даалгавруудыг унших", + "body": "Таны даалгаврын жагсаалт болон даалгавруудыг харах." + }, + "tasks_write": { + "title": "Таны даалгавруудыг өөрчлөх", + "body": "Даалгавар үүсгэх, өөрчлөх, дуусгах, устгах." + }, + "templates_read": { + "title": "Таны загваруудыг унших", + "body": "Таны хадгалсан имэйлийн загваруудыг харах." + }, + "templates_write": { + "title": "Таны загваруудыг өөрчлөх", + "body": "Имэйлийн загвар үүсгэх, өөрчлөх, устгах." + }, + "smime_read": { + "title": "Таны S/MIME төлөвийг харах", + "body": "Танд S/MIME түлхүүр, гэрчилгээ байгаа эсэхийг түлхүүрт хандахгүйгээр харах." + }, + "vacation_read": { + "title": "Таны эзгүй үеийн хариуг харах", + "body": "Автомат хариу асаалттай эсэх, юу бичсэнийг харах." + }, + "vacation_write": { + "title": "Таны эзгүй үеийн хариуг өөрчлөх", + "body": "Автомат хариуг асаах, унтраах, текст болон огноог нь өөрчлөх." + }, + "settings_read": { + "title": "Таны тохиргоог унших", + "body": "Таны тохиргоо болон тагуудын тодорхойлолтыг харах." + }, + "settings_write": { + "title": "Таны тохиргоог өөрчлөх", + "body": "Таг нэмэх, дарааллыг нь өөрчлөх, таны тохиргоог өөрчлөх." + }, + "security_read": { + "title": "Бүртгэлийн аюулгүй байдлыг харах", + "body": "Хоёр хүчин зүйлийн баталгаажуулалт болон шифрлэлт асаалттай эсэхийг нууц мэдээлэлд хандахгүйгээр харах." + }, + "auth_observe": { + "title": "Таныг нэвтрэхийг мэдэх", + "body": "Таныг нэвтрэх, бүртгэл солих эсвэл сесс сунгагдах үед мэдэгдэл авах." + }, + "auth_emit": { + "title": "Таныг гаргах", + "body": "Таныг webmail-ээс гаргах." + }, + "account_read": { + "title": "Таны бүртгэлүүдийг харах", + "body": "Таны нэмсэн бүртгэлүүдийг хаяг, серверийн хамт харах." + }, + "http_post": { + "title": "Таны өмнөөс webmail серверийг ашиглах", + "body": "Эдгээр серверийн хаяг руу таны нэвтрэх мэдээллээр хандах:" + }, + "http_fetch": { + "title": "Бусад вэбсайттай холбогдох", + "body": "Эдгээр сайт руу хүсэлт илгээх. Залгаасын уншиж чадах бүх зүйлийг тийш илгээж болзошгүй:" + }, + "admin_config": { + "title": "Администраторын тохиргоог унших", + "body": "Таны администраторын энэ залгаасад хадгалсан тохиргоог унших." + }, + "ui_observe": { + "title": "Апп дотор хаана байгааг дагах", + "body": "Аль хуудас нээлттэй байгааг мэдэх, таны дарсан холбоосод хариу үйлдэл үзүүлэх." + }, + "ui_toolbar": { + "title": "Имэйлийн хэрэгслийн мөрөнд товч нэмэх", + "body": "Нээлттэй имэйлийн дээр өөрийн товчуудыг харуулах." + }, + "ui_app_top_banner": { + "title": "Дээд хэсэгт баннер харуулах", + "body": "Аппын дээд хэсэгт мэдэгдэл харуулах." + }, + "ui_email_banner": { + "title": "Имэйл дээр мэдэгдэл харуулах", + "body": "Нээлттэй имэйлийн текстийн дээр мэдэгдэл харуулах." + }, + "ui_email_footer": { + "title": "Имэйлийн доор агуулга нэмэх", + "body": "Нээлттэй имэйлийн доор өөрийн агуулгыг харуулах." + }, + "ui_contact_cryptokeys": { + "title": "Харилцагчийн шифрлэлтийн түлхүүрийг харуулах", + "body": "Харилцагчийн шифрлэлтийн түлхүүрүүдийг харуулах, удирдах." + }, + "ui_email_details": { + "title": "Имэйлийн дэлгэрэнгүйд самбар нэмэх", + "body": "Нээлттэй имэйлийн хажууд эсвэл доор өөрийн самбарыг харуулах." + }, + "ui_download_file": { + "title": "Файлыг таны төхөөрөмжид хадгалах", + "body": "Өөрийн үүсгэсэн файлуудыг татаж авахыг санал болгох." + }, + "ui_message_list_tabs": { + "title": "Ирсэн имэйлд таб нэмэх", + "body": "Ирсэн имэйлийг Шинэчлэлт, Сурталчилгаа зэрэг табуудад ангилах." + }, + "ui_composer_toolbar": { + "title": "Имэйл бичих цонхонд товч нэмэх", + "body": "Таныг имэйл бичих үед өөрийн товчуудыг харуулах." + }, + "ui_composer_sidebar": { + "title": "Имэйл бичих цонхонд самбар нэмэх", + "body": "Таны бичиж буй имэйлийн хажууд өөрийн самбарыг харуулах." + }, + "ui_sidebar_widget": { + "title": "Хажуугийн самбарт хэсэг нэмэх", + "body": "Имэйлийн хажуугийн самбарт өөрийн агуулгыг харуулах." + }, + "ui_settings_section": { + "title": "Тохиргооны хуудас нэмэх", + "body": "Тохиргоо хэсэгт өөрийн хуудсыг харуулах." + }, + "ui_context_menu": { + "title": "Имэйлийн цэсэнд нэмэх", + "body": "Имэйл дээр баруун товшиход нээгддэг цэсэнд зүйл нэмэх." + }, + "ui_navigation_rail": { + "title": "Навигацын мөрөнд нэмэх", + "body": "Навигацын мөрөнд өөрийн товчийг харуулах." + }, + "ui_keyboard": { + "title": "Товчлуурын товчлол нэмэх", + "body": "Өөрийн тодорхойлсон товчлуурын товчлолд хариу үйлдэл үзүүлэх." + }, + "ui_calendar_action": { + "title": "Үйл явдалд товч нэмэх", + "body": "Таныг үйл явдал үүсгэх эсвэл засах үед өөрийн товчуудыг харуулах." + }, + "ui_admin_page": { + "title": "Админ хуудас нэмэх", + "body": "Админы тохиргоонд өөрийн хуудсыг харуулах." + }, + "app_lifecycle": { + "title": "Апп эхлэх, зогсохыг мэдэх", + "body": "Апп эхлэх, офлайн болох эсвэл таныг гарах үед мэдэгдэл авах." + } } }, "global_search": { diff --git a/locales/nb/common.json b/locales/nb/common.json index c7127ca02..c23c8dbbd 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorisering mislyktes: {error}", "missing_params": "Mangler autorisasjonskode eller tilstand.", "delivery_failed": "Autoriseringsresultatet kunne ikke leveres til app-fanen. Lukk dette vinduet og prøv igjen." + }, + "settings": { + "title": "Tillegg", + "description": "Tillegg som administratoren din har lagt til. Slå dem på eller av for kontoen din.", + "description_uploads": "Tillegg gir webmailen flere funksjoner. Slå dem på eller av for kontoen din, eller installer dine egne.", + "description_demo": "Tillegg er ikke tilgjengelige i demoen.", + "install_button": "Installer tillegg", + "install_hint": "Velg en ZIP-fil med et tillegg.", + "install_hint_approval": "Velg en ZIP-fil med et tillegg. En administrator må godkjenne det før du kan slå det på.", + "install_uploads_off": "Administratoren din tillater ikke at du installerer tillegg.", + "install_managed_id": "Administratoren din tilbyr allerede et tillegg med dette navnet, så det kan ikke erstattes.", + "install_invalid": "Denne filen er ikke et tillegg som kan installeres: {details}", + "toast_installed": "Tillegget er installert. Slå det på nedenfor.", + "toast_installed_pending": "Tillegget er installert og sendt til administratoren din for godkjenning.", + "toast_enabled": "{name} er på", + "toast_disabled": "{name} er av", + "toast_removed": "{name} er fjernet", + "toast_forced": "Administratoren din holder {name} slått på", + "toast_approval_requested": "{name} venter på godkjenning fra administratoren din", + "empty_title": "Ingen tillegg ennå", + "empty_admin": "Administratoren din har ikke lagt til noen tillegg.", + "empty_uploads": "Installer et tillegg fra en ZIP-fil for å komme i gang.", + "empty_demo": "Tillegg krever en ekte konto, så demoen har ingen.", + "syncing": "Sjekker tilleggene mot serveren…", + "status_installed": "Av", + "status_enabled": "Starter", + "status_running": "På", + "status_disabled": "Av", + "status_error": "Problem", + "badge_forced": "Påkrevd", + "badge_managed": "Fra administratoren din", + "badge_yours": "Installert av deg", + "badge_awaiting": "Venter på godkjenning", + "toggle_label": "Slå {name} på eller av", + "forced_note": "Administratoren din krever dette tillegget, så det kan ikke slås av.", + "permissions": "Hva det kan gjøre", + "permissions_managed": "Hva det kan gjøre (godkjent av administratoren din)", + "settings": "Innstillinger", + "remove": "Fjern", + "remove_title": "Vil du fjerne {name}?", + "remove_message": "Tillegget og alt det har lagret i denne nettleseren, blir slettet. Du kan installere det på nytt senere.", + "remove_confirm": "Fjern", + "error_user_plugins_off": "Administratoren din har slått av tillegg som brukere har installert, så dette tillegget kjører ikke.", + "error_approval_unavailable": "Dette tillegget ble installert før godkjenning ble påkrevd. Fjern det og installer det på nytt for å sende det til godkjenning.", + "error_approval_denied": "Administratoren din godkjente ikke dette tillegget.", + "error_approval_pending": "Venter på at administratoren din skal godkjenne dette tillegget." + }, + "consent": { + "title": "Vil du la {name} gjøre dette?", + "intro": "Tillegget ber om tillatelsene nedenfor. Det får dem bare hvis du tillater det.", + "high_risk_note": "Merkede punkter gir tillegget tilgang til innholdet i e-posten din eller lar det sende data andre steder. Tillat dem bare for tillegg du stoler på.", + "revoke_hint": "Du kan når som helst slå av eller fjerne tillegget under Innstillinger.", + "deny": "Ikke tillat", + "allow": "Tillat" + }, + "permissions": { + "none": "Det trenger ingen spesielle tillatelser.", + "high_risk": "Krever varsomhet", + "email_read": { + "title": "Lese e-posten din", + "body": "Se avsendere, mottakere, emner og hele teksten i meldingene du åpner og i meldingslisten." + }, + "email_write": { + "title": "Endre etiketter og flagg på e-posten din", + "body": "Legge til eller fjerne etiketter, flagg og andre nøkkelord på meldingene dine." + }, + "email_send": { + "title": "Endre e-post før den sendes", + "body": "Lese og endre mottakere, emne, tekst og vedlegg i meldingene du skriver, og stoppe en melding fra å bli sendt." + }, + "crypto_full": { + "title": "Full tilgang til denne appen", + "body": "Kjører med de samme rettighetene som selve webmailen. Det kan lese all e-posten din og lagrede nøkler og handle på dine vegne. Tillat dette bare for tillegg du stoler fullt på." + }, + "email_raw_send": { + "title": "Sende meldinger det har laget selv", + "body": "Sende ferdige meldinger, for eksempel signerte eller krypterte, fra kontoen din." + }, + "email_blob_read": { + "title": "Lese råinnholdet i meldinger", + "body": "Lese de opprinnelige dataene i meldingene og vedleggene dine, og i hver fil du legger ved før den sendes." + }, + "email_blob_write": { + "title": "Erstatte vedlegg før sending", + "body": "Laste opp filer til e-postserveren din og erstatte et vedlegg med en annen fil før meldingen sendes." + }, + "email_render_takeover": { + "title": "Endre hvordan meldinger vises", + "body": "Erstatte teksten i meldingene du åpner og i meldingslisten, for eksempel for å vise dekryptert innhold." + }, + "calendar_read": { + "title": "Lese kalenderen din", + "body": "Se kalenderne dine og hendelsene i dem." + }, + "calendar_write": { + "title": "Endre kalenderen din", + "body": "Opprette, endre og slette hendelser." + }, + "contacts_read": { + "title": "Lese kontaktene dine", + "body": "Søke i og lese adressebøkene dine." + }, + "contacts_write": { + "title": "Endre kontaktene dine", + "body": "Opprette, endre og slette kontakter og adressebøker." + }, + "files_read": { + "title": "Lese filene dine", + "body": "Bla gjennom og åpne filene som er lagret på kontoen din." + }, + "files_write": { + "title": "Endre filene dine", + "body": "Laste opp, gi nytt navn til, flytte og slette filer på kontoen din." + }, + "identity_read": { + "title": "Se avsenderidentitetene dine", + "body": "Se adressene du sender fra, og signaturene deres." + }, + "identity_write": { + "title": "Endre avsenderidentitetene dine", + "body": "Opprette, endre og slette avsenderidentiteter og signaturer." + }, + "filters_read": { + "title": "Lese e-postfiltrene dine", + "body": "Se reglene som sorterer innkommende e-post." + }, + "filters_write": { + "title": "Endre e-postfiltrene dine", + "body": "Legge til regler i filtrene dine på serveren. Slike regler kan flytte, videresende eller slette innkommende e-post." + }, + "tasks_read": { + "title": "Lese oppgavene dine", + "body": "Se oppgavelistene og oppgavene dine." + }, + "tasks_write": { + "title": "Endre oppgavene dine", + "body": "Opprette, endre, fullføre og slette oppgaver." + }, + "templates_read": { + "title": "Lese malene dine", + "body": "Se de lagrede meldingsmalene dine." + }, + "templates_write": { + "title": "Endre malene dine", + "body": "Opprette, endre og slette meldingsmaler." + }, + "smime_read": { + "title": "Se S/MIME-statusen din", + "body": "Se om du har S/MIME-nøkler og -sertifikater, uten tilgang til nøklene." + }, + "vacation_read": { + "title": "Se fraværssvaret ditt", + "body": "Se om det automatiske svaret ditt er på, og hva det sier." + }, + "vacation_write": { + "title": "Endre fraværssvaret ditt", + "body": "Slå det automatiske svaret på eller av og endre teksten og datoene." + }, + "settings_read": { + "title": "Lese innstillingene dine", + "body": "Se innstillingene og etikettdefinisjonene dine." + }, + "settings_write": { + "title": "Endre innstillingene dine", + "body": "Legge til og endre rekkefølgen på etiketter og endre innstillingene dine." + }, + "security_read": { + "title": "Se kontosikkerheten din", + "body": "Se om tofaktorautentisering og kryptering er slått på, uten tilgang til hemmeligheter." + }, + "auth_observe": { + "title": "Vite når du logger på", + "body": "Få beskjed når du logger på, bytter konto eller økten din fornyes." + }, + "auth_emit": { + "title": "Logge deg ut", + "body": "Logge deg ut av webmailen." + }, + "account_read": { + "title": "Se kontoene dine", + "body": "Se kontoene du har lagt til, med adressene og serverne deres." + }, + "http_post": { + "title": "Bruke webmailserveren for deg", + "body": "Kalle disse serverendepunktene med påloggingen din:" + }, + "http_fetch": { + "title": "Koble til andre nettsteder", + "body": "Sende forespørsler til disse nettstedene. Alt tillegget kan lese, kan bli sendt dit:" + }, + "admin_config": { + "title": "Lese innstillingene sine fra administratoren din", + "body": "Lese innstillingene administratoren din har lagret for dette tillegget." + }, + "ui_observe": { + "title": "Følge med på hvor du er i appen", + "body": "Vite hvilken side som er åpen, og reagere på lenker du klikker på." + }, + "ui_toolbar": { + "title": "Legge til knapper i meldingsverktøylinjen", + "body": "Vise egne knapper over en åpen melding." + }, + "ui_app_top_banner": { + "title": "Vise et banner øverst", + "body": "Vise et varsel over hele toppen av appen." + }, + "ui_email_banner": { + "title": "Vise varsler på meldinger", + "body": "Vise et varsel over teksten i en åpen melding." + }, + "ui_email_footer": { + "title": "Legge til innhold under meldinger", + "body": "Vise eget innhold under en åpen melding." + }, + "ui_contact_cryptokeys": { + "title": "Vise krypteringsnøklene til kontakter", + "body": "Vise og administrere krypteringsnøklene på en kontakt." + }, + "ui_email_details": { + "title": "Legge til paneler i meldingsdetaljene", + "body": "Vise et eget panel ved siden av eller under en åpen melding." + }, + "ui_download_file": { + "title": "Lagre filer på enheten din", + "body": "Tilby filer det har laget, for nedlasting." + }, + "ui_message_list_tabs": { + "title": "Legge til faner i innboksen din", + "body": "Sortere innboksen din i faner som Oppdateringer eller Kampanjer." + }, + "ui_composer_toolbar": { + "title": "Legge til knapper i meldingsvinduet", + "body": "Vise egne knapper mens du skriver en melding." + }, + "ui_composer_sidebar": { + "title": "Legge til et panel i meldingsvinduet", + "body": "Vise et eget panel ved siden av meldingen du skriver." + }, + "ui_sidebar_widget": { + "title": "Legge til en boks i sidefeltet", + "body": "Vise eget innhold i sidefeltet for e-post." + }, + "ui_settings_section": { + "title": "Legge til en innstillingsside", + "body": "Vise en egen side under Innstillinger." + }, + "ui_context_menu": { + "title": "Legge til i meldingsmenyen", + "body": "Legge til valg i menyen som åpnes når du høyreklikker på en melding." + }, + "ui_navigation_rail": { + "title": "Legge til i navigasjonsfeltet", + "body": "Vise en egen knapp i navigasjonsfeltet." + }, + "ui_keyboard": { + "title": "Legge til tastatursnarveier", + "body": "Reagere på tastatursnarveier det definerer selv." + }, + "ui_calendar_action": { + "title": "Legge til knapper på hendelser", + "body": "Vise egne knapper når du oppretter eller redigerer en hendelse." + }, + "ui_admin_page": { + "title": "Legge til en administrasjonsside", + "body": "Vise en egen side i administrasjonsinnstillingene." + }, + "app_lifecycle": { + "title": "Vite når appen starter og stopper", + "body": "Få beskjed når appen starter, går frakoblet eller du logger ut." + } } }, "global_search": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 1be74af98..d801d2f78 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorisatie mislukt: {error}", "missing_params": "Autorisatiecode of status ontbreekt.", "delivery_failed": "Het autorisatieresultaat kon niet worden afgeleverd bij het app-tabblad. Sluit dit venster en probeer het opnieuw." + }, + "settings": { + "title": "Plug-ins", + "description": "Plug-ins die je beheerder heeft toegevoegd. Zet ze aan of uit voor je account.", + "description_uploads": "Plug-ins voegen functies toe aan de webmail. Zet ze aan of uit voor je account, of installeer je eigen plug-ins.", + "description_demo": "Plug-ins zijn niet beschikbaar in de demo.", + "install_button": "Plug-in installeren", + "install_hint": "Kies een ZIP-bestand met een plug-in.", + "install_hint_approval": "Kies een ZIP-bestand met een plug-in. Een beheerder moet de plug-in goedkeuren voordat je hem kunt aanzetten.", + "install_uploads_off": "Je beheerder staat het installeren van plug-ins niet toe.", + "install_managed_id": "Je beheerder biedt al een plug-in met deze naam aan, dus die kan niet worden vervangen.", + "install_invalid": "Dit bestand is geen plug-in die kan worden geïnstalleerd: {details}", + "toast_installed": "Plug-in geïnstalleerd. Zet hem hieronder aan.", + "toast_installed_pending": "Plug-in geïnstalleerd en ter goedkeuring naar je beheerder gestuurd.", + "toast_enabled": "{name} staat aan", + "toast_disabled": "{name} staat uit", + "toast_removed": "{name} is verwijderd", + "toast_forced": "Je beheerder houdt {name} aan", + "toast_approval_requested": "{name} wacht op goedkeuring door je beheerder", + "empty_title": "Nog geen plug-ins", + "empty_admin": "Je beheerder heeft geen plug-ins toegevoegd.", + "empty_uploads": "Installeer een plug-in vanuit een ZIP-bestand om te beginnen.", + "empty_demo": "Plug-ins hebben een echt account nodig, dus de demo heeft er geen.", + "syncing": "Plug-ins controleren bij de server…", + "status_installed": "Uit", + "status_enabled": "Wordt gestart", + "status_running": "Aan", + "status_disabled": "Uit", + "status_error": "Probleem", + "badge_forced": "Verplicht", + "badge_managed": "Van je beheerder", + "badge_yours": "Door jou geïnstalleerd", + "badge_awaiting": "Wacht op goedkeuring", + "toggle_label": "{name} aan- of uitzetten", + "forced_note": "Je beheerder vereist deze plug-in, dus hij kan niet worden uitgezet.", + "permissions": "Wat hij kan doen", + "permissions_managed": "Wat hij kan doen (goedgekeurd door je beheerder)", + "settings": "Instellingen", + "remove": "Verwijderen", + "remove_title": "{name} verwijderen?", + "remove_message": "De plug-in en alles wat hij in deze browser heeft opgeslagen, worden verwijderd. Je kunt hem later opnieuw installeren.", + "remove_confirm": "Verwijderen", + "error_user_plugins_off": "Je beheerder heeft plug-ins die door gebruikers zijn geïnstalleerd uitgezet, dus deze plug-in draait niet.", + "error_approval_unavailable": "Deze plug-in is geïnstalleerd voordat goedkeuring verplicht was. Verwijder hem en installeer hem opnieuw om hem ter goedkeuring te sturen.", + "error_approval_denied": "Je beheerder heeft deze plug-in niet goedgekeurd.", + "error_approval_pending": "Wacht tot je beheerder deze plug-in goedkeurt." + }, + "consent": { + "title": "{name} dit toestaan?", + "intro": "De plug-in vraagt de onderstaande rechten. Hij krijgt ze alleen als je ze toestaat.", + "high_risk_note": "Gemarkeerde items geven de plug-in toegang tot de inhoud van je e-mail of laten hem gegevens elders naartoe sturen. Sta ze alleen toe voor plug-ins die je vertrouwt.", + "revoke_hint": "Je kunt de plug-in op elk moment uitzetten of verwijderen in Instellingen.", + "deny": "Niet toestaan", + "allow": "Toestaan" + }, + "permissions": { + "none": "Hij heeft geen speciale rechten nodig.", + "high_risk": "Let op", + "email_read": { + "title": "Je e-mail lezen", + "body": "Afzenders, ontvangers, onderwerpen en de volledige tekst zien van berichten die je opent en van de berichtenlijst." + }, + "email_write": { + "title": "Labels en markeringen van je e-mail wijzigen", + "body": "Labels, markeringen en andere trefwoorden aan je berichten toevoegen of ervan verwijderen." + }, + "email_send": { + "title": "E-mail wijzigen voordat die wordt verzonden", + "body": "De ontvangers, het onderwerp, de tekst en de bijlagen lezen en wijzigen van berichten die je schrijft, en het verzenden van een bericht tegenhouden." + }, + "crypto_full": { + "title": "Volledige toegang tot deze app", + "body": "Draait met dezelfde rechten als de webmail zelf. Hij kan al je e-mail en opgeslagen sleutels lezen en namens jou handelen. Sta dit alleen toe voor plug-ins die je volledig vertrouwt." + }, + "email_raw_send": { + "title": "Zelf samengestelde berichten verzenden", + "body": "Volledige berichten, bijvoorbeeld ondertekende of versleutelde, vanaf je account verzenden." + }, + "email_blob_read": { + "title": "De ruwe inhoud van berichten lezen", + "body": "De oorspronkelijke gegevens van je berichten en bijlagen lezen, en van elk bestand dat je bijvoegt voordat het wordt verzonden." + }, + "email_blob_write": { + "title": "Bijlagen vervangen vóór het verzenden", + "body": "Bestanden naar je mailserver uploaden en een bijgevoegd bestand door een ander vervangen voordat het bericht wordt verzonden." + }, + "email_render_takeover": { + "title": "Wijzigen hoe berichten worden weergegeven", + "body": "De tekst vervangen van berichten die je opent en van de berichtenlijst, bijvoorbeeld om ontsleutelde inhoud te tonen." + }, + "calendar_read": { + "title": "Je agenda lezen", + "body": "Je agenda's en hun afspraken zien." + }, + "calendar_write": { + "title": "Je agenda wijzigen", + "body": "Afspraken aanmaken, wijzigen en verwijderen." + }, + "contacts_read": { + "title": "Je contacten lezen", + "body": "Je adresboeken doorzoeken en lezen." + }, + "contacts_write": { + "title": "Je contacten wijzigen", + "body": "Contacten en adresboeken aanmaken, wijzigen en verwijderen." + }, + "files_read": { + "title": "Je bestanden lezen", + "body": "De bestanden in je account bekijken en openen." + }, + "files_write": { + "title": "Je bestanden wijzigen", + "body": "Bestanden in je account uploaden, hernoemen, verplaatsen en verwijderen." + }, + "identity_read": { + "title": "Je afzenderadressen zien", + "body": "De adressen waarvandaan je verzendt en hun handtekeningen zien." + }, + "identity_write": { + "title": "Je afzenderadressen wijzigen", + "body": "Afzenderadressen en handtekeningen aanmaken, wijzigen en verwijderen." + }, + "filters_read": { + "title": "Je mailfilters lezen", + "body": "De regels zien die je binnenkomende e-mail sorteren." + }, + "filters_write": { + "title": "Je mailfilters wijzigen", + "body": "Regels toevoegen aan je filters op de server. Zulke regels kunnen binnenkomende e-mail verplaatsen, doorsturen of verwijderen." + }, + "tasks_read": { + "title": "Je taken lezen", + "body": "Je takenlijsten en taken zien." + }, + "tasks_write": { + "title": "Je taken wijzigen", + "body": "Taken aanmaken, wijzigen, afronden en verwijderen." + }, + "templates_read": { + "title": "Je sjablonen lezen", + "body": "Je opgeslagen berichtsjablonen zien." + }, + "templates_write": { + "title": "Je sjablonen wijzigen", + "body": "Berichtsjablonen aanmaken, wijzigen en verwijderen." + }, + "smime_read": { + "title": "Je S/MIME-status zien", + "body": "Zien of je S/MIME-sleutels en -certificaten hebt, zonder toegang tot de sleutels." + }, + "vacation_read": { + "title": "Je afwezigheidsmelding zien", + "body": "Zien of je automatisch antwoord aanstaat en wat erin staat." + }, + "vacation_write": { + "title": "Je afwezigheidsmelding wijzigen", + "body": "Je automatisch antwoord aan- of uitzetten en de tekst en datums ervan wijzigen." + }, + "settings_read": { + "title": "Je instellingen lezen", + "body": "Je voorkeuren en labeldefinities zien." + }, + "settings_write": { + "title": "Je instellingen wijzigen", + "body": "Labels toevoegen en herschikken en je voorkeuren wijzigen." + }, + "security_read": { + "title": "De beveiliging van je account zien", + "body": "Zien of tweefactoraanmelding en versleuteling aanstaan, zonder toegang tot geheimen." + }, + "auth_observe": { + "title": "Weten wanneer je je aanmeldt", + "body": "Een melding krijgen wanneer je je aanmeldt, van account wisselt of je sessie wordt vernieuwd." + }, + "auth_emit": { + "title": "Je afmelden", + "body": "Je afmelden bij de webmail." + }, + "account_read": { + "title": "Je accounts zien", + "body": "De accounts zien die je hebt toegevoegd, met hun adressen en servers." + }, + "http_post": { + "title": "De webmailserver voor je gebruiken", + "body": "Deze servereindpunten aanroepen met je aanmelding:" + }, + "http_fetch": { + "title": "Verbinding maken met andere websites", + "body": "Verzoeken naar deze sites sturen. Alles wat de plug-in kan lezen, kan daarheen worden gestuurd:" + }, + "admin_config": { + "title": "Zijn instellingen van je beheerder lezen", + "body": "De instellingen lezen die je beheerder voor deze plug-in heeft opgeslagen." + }, + "ui_observe": { + "title": "Volgen waar je bent in de app", + "body": "Weten welke pagina open is en reageren op links waarop je klikt." + }, + "ui_toolbar": { + "title": "Knoppen toevoegen aan de berichtwerkbalk", + "body": "Eigen knoppen tonen boven een geopend bericht." + }, + "ui_app_top_banner": { + "title": "Een banner bovenaan tonen", + "body": "Een melding tonen over de hele bovenkant van de app." + }, + "ui_email_banner": { + "title": "Meldingen bij berichten tonen", + "body": "Een melding tonen boven de tekst van een geopend bericht." + }, + "ui_email_footer": { + "title": "Inhoud onder berichten toevoegen", + "body": "Eigen inhoud tonen onder een geopend bericht." + }, + "ui_contact_cryptokeys": { + "title": "Versleutelingssleutels van contacten tonen", + "body": "De versleutelingssleutels van een contact tonen en beheren." + }, + "ui_email_details": { + "title": "Panelen toevoegen aan berichtdetails", + "body": "Een eigen paneel tonen naast of onder een geopend bericht." + }, + "ui_download_file": { + "title": "Bestanden op je apparaat opslaan", + "body": "Bestanden die hij heeft gemaakt aanbieden om te downloaden." + }, + "ui_message_list_tabs": { + "title": "Tabbladen toevoegen aan je Postvak IN", + "body": "Je Postvak IN in tabbladen indelen, zoals Updates of Promoties." + }, + "ui_composer_toolbar": { + "title": "Knoppen toevoegen aan de opsteller", + "body": "Eigen knoppen tonen terwijl je een bericht schrijft." + }, + "ui_composer_sidebar": { + "title": "Een paneel toevoegen aan de opsteller", + "body": "Een eigen paneel tonen naast het bericht dat je schrijft." + }, + "ui_sidebar_widget": { + "title": "Een vak toevoegen aan de zijbalk", + "body": "Eigen inhoud tonen in de zijbalk van de mail." + }, + "ui_settings_section": { + "title": "Een instellingenpagina toevoegen", + "body": "Een eigen pagina tonen in Instellingen." + }, + "ui_context_menu": { + "title": "Toevoegen aan het berichtmenu", + "body": "Items toevoegen aan het menu dat opent als je met de rechtermuisknop op een bericht klikt." + }, + "ui_navigation_rail": { + "title": "Toevoegen aan de navigatiebalk", + "body": "Een eigen knop tonen in de navigatiebalk." + }, + "ui_keyboard": { + "title": "Sneltoetsen toevoegen", + "body": "Reageren op sneltoetsen die hij zelf vastlegt." + }, + "ui_calendar_action": { + "title": "Knoppen toevoegen aan afspraken", + "body": "Eigen knoppen tonen wanneer je een afspraak aanmaakt of bewerkt." + }, + "ui_admin_page": { + "title": "Een beheerpagina toevoegen", + "body": "Een eigen pagina tonen in de beheerinstellingen." + }, + "app_lifecycle": { + "title": "Weten wanneer de app start en stopt", + "body": "Een melding krijgen wanneer de app start, offline gaat of je je afmeldt." + } } }, "global_search": { diff --git a/locales/pl/common.json b/locales/pl/common.json index e9c7e01ce..f38b2509d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autoryzacja nie powiodła się: {error}", "missing_params": "Brak kodu autoryzacji lub stanu.", "delivery_failed": "Nie udało się przekazać wyniku autoryzacji do karty aplikacji. Zamknij to okno i spróbuj ponownie." + }, + "settings": { + "title": "Wtyczki", + "description": "Wtyczki dodane przez administratora. Włączaj je i wyłączaj dla swojego konta.", + "description_uploads": "Wtyczki dodają funkcje do webmaila. Włączaj je i wyłączaj dla swojego konta albo instaluj własne.", + "description_demo": "Wtyczki nie są dostępne w wersji demo.", + "install_button": "Zainstaluj wtyczkę", + "install_hint": "Wybierz plik ZIP z wtyczką.", + "install_hint_approval": "Wybierz plik ZIP z wtyczką. Zanim będzie można ją włączyć, administrator musi ją zatwierdzić.", + "install_uploads_off": "Administrator nie pozwala na instalowanie wtyczek.", + "install_managed_id": "Administrator udostępnia już wtyczkę o tej nazwie, więc nie można jej zastąpić.", + "install_invalid": "Tego pliku nie można zainstalować jako wtyczki: {details}", + "toast_installed": "Wtyczka zainstalowana. Włącz ją poniżej.", + "toast_installed_pending": "Wtyczka zainstalowana i wysłana do zatwierdzenia przez administratora.", + "toast_enabled": "Wtyczka {name} jest włączona", + "toast_disabled": "Wtyczka {name} jest wyłączona", + "toast_removed": "Usunięto wtyczkę {name}", + "toast_forced": "Administrator utrzymuje wtyczkę {name} włączoną", + "toast_approval_requested": "Wtyczka {name} czeka na zatwierdzenie przez administratora", + "empty_title": "Brak wtyczek", + "empty_admin": "Administrator nie dodał jeszcze żadnych wtyczek.", + "empty_uploads": "Na początek zainstaluj wtyczkę z pliku ZIP.", + "empty_demo": "Wtyczki wymagają prawdziwego konta, więc w wersji demo ich nie ma.", + "syncing": "Sprawdzanie wtyczek na serwerze…", + "status_installed": "Wyłączona", + "status_enabled": "Uruchamianie", + "status_running": "Włączona", + "status_disabled": "Wyłączona", + "status_error": "Problem", + "badge_forced": "Wymagana", + "badge_managed": "Od administratora", + "badge_yours": "Zainstalowana przez Ciebie", + "badge_awaiting": "Czeka na zatwierdzenie", + "toggle_label": "Włącz lub wyłącz wtyczkę {name}", + "forced_note": "Administrator wymaga tej wtyczki, więc nie można jej wyłączyć.", + "permissions": "Co może robić", + "permissions_managed": "Co może robić (zatwierdzone przez administratora)", + "settings": "Ustawienia", + "remove": "Usuń", + "remove_title": "Usunąć wtyczkę {name}?", + "remove_message": "Wtyczka i wszystko, co zapisała w tej przeglądarce, zostaną usunięte. Możesz ją później zainstalować ponownie.", + "remove_confirm": "Usuń", + "error_user_plugins_off": "Administrator wyłączył wtyczki instalowane przez użytkowników, więc ta wtyczka nie działa.", + "error_approval_unavailable": "Ta wtyczka została zainstalowana, zanim wprowadzono zatwierdzanie. Usuń ją i zainstaluj ponownie, aby wysłać ją do zatwierdzenia.", + "error_approval_denied": "Administrator nie zatwierdził tej wtyczki.", + "error_approval_pending": "Czeka na zatwierdzenie przez administratora." + }, + "consent": { + "title": "Zezwolić wtyczce {name} na te działania?", + "intro": "Wtyczka prosi o poniższe uprawnienia. Otrzyma je tylko wtedy, gdy na to zezwolisz.", + "high_risk_note": "Oznaczone pozycje dają wtyczce dostęp do treści Twojej poczty lub pozwalają jej wysyłać dane gdzie indziej. Zezwalaj na nie tylko zaufanym wtyczkom.", + "revoke_hint": "W każdej chwili możesz wyłączyć lub usunąć wtyczkę w Ustawieniach.", + "deny": "Nie zezwalaj", + "allow": "Zezwól" + }, + "permissions": { + "none": "Nie potrzebuje specjalnych uprawnień.", + "high_risk": "Wymaga ostrożności", + "email_read": { + "title": "Czytanie poczty", + "body": "Widzi nadawców, odbiorców, tematy i pełną treść otwieranych wiadomości oraz listy wiadomości." + }, + "email_write": { + "title": "Zmiana etykiet i flag w poczcie", + "body": "Dodaje i usuwa etykiety, flagi i inne słowa kluczowe w Twoich wiadomościach." + }, + "email_send": { + "title": "Zmiana poczty przed wysłaniem", + "body": "Odczytuje i zmienia odbiorców, temat, treść i załączniki pisanych wiadomości oraz może wstrzymać wysłanie wiadomości." + }, + "crypto_full": { + "title": "Pełny dostęp do aplikacji", + "body": "Działa z tymi samymi uprawnieniami co sam webmail. Może czytać całą Twoją pocztę i zapisane klucze oraz działać w Twoim imieniu. Zezwalaj na to tylko wtyczkom, którym w pełni ufasz." + }, + "email_raw_send": { + "title": "Wysyłanie wiadomości utworzonych przez wtyczkę", + "body": "Wysyła z Twojego konta kompletne wiadomości, na przykład podpisane lub zaszyfrowane." + }, + "email_blob_read": { + "title": "Odczyt surowej treści wiadomości", + "body": "Odczytuje oryginalne dane Twoich wiadomości i załączników oraz każdego pliku, który załączysz przed wysłaniem." + }, + "email_blob_write": { + "title": "Zastępowanie załączników przed wysłaniem", + "body": "Przesyła pliki na Twój serwer poczty i zastępuje załączony plik innym, zanim wiadomość zostanie wysłana." + }, + "email_render_takeover": { + "title": "Zmiana sposobu wyświetlania wiadomości", + "body": "Zastępuje treść otwieranych wiadomości i listy wiadomości, na przykład aby pokazać odszyfrowaną zawartość." + }, + "calendar_read": { + "title": "Odczyt kalendarza", + "body": "Widzi Twoje kalendarze i ich wydarzenia." + }, + "calendar_write": { + "title": "Zmiana kalendarza", + "body": "Tworzy, zmienia i usuwa wydarzenia." + }, + "contacts_read": { + "title": "Odczyt kontaktów", + "body": "Przeszukuje i odczytuje Twoje książki adresowe." + }, + "contacts_write": { + "title": "Zmiana kontaktów", + "body": "Tworzy, zmienia i usuwa kontakty i książki adresowe." + }, + "files_read": { + "title": "Odczyt plików", + "body": "Przegląda i otwiera pliki zapisane na Twoim koncie." + }, + "files_write": { + "title": "Zmiana plików", + "body": "Przesyła, zmienia nazwy, przenosi i usuwa pliki na Twoim koncie." + }, + "identity_read": { + "title": "Podgląd tożsamości nadawcy", + "body": "Widzi adresy, z których wysyłasz wiadomości, i ich podpisy." + }, + "identity_write": { + "title": "Zmiana tożsamości nadawcy", + "body": "Tworzy, zmienia i usuwa tożsamości nadawcy i podpisy." + }, + "filters_read": { + "title": "Odczyt filtrów poczty", + "body": "Widzi reguły, które sortują Twoją przychodzącą pocztę." + }, + "filters_write": { + "title": "Zmiana filtrów poczty", + "body": "Dodaje reguły do filtrów na serwerze. Takie reguły mogą przenosić, przekazywać dalej lub usuwać przychodzącą pocztę." + }, + "tasks_read": { + "title": "Odczyt zadań", + "body": "Widzi Twoje listy zadań i zadania." + }, + "tasks_write": { + "title": "Zmiana zadań", + "body": "Tworzy, zmienia, kończy i usuwa zadania." + }, + "templates_read": { + "title": "Odczyt szablonów", + "body": "Widzi Twoje zapisane szablony wiadomości." + }, + "templates_write": { + "title": "Zmiana szablonów", + "body": "Tworzy, zmienia i usuwa szablony wiadomości." + }, + "smime_read": { + "title": "Podgląd stanu S/MIME", + "body": "Widzi, czy masz klucze i certyfikaty S/MIME, bez dostępu do kluczy." + }, + "vacation_read": { + "title": "Podgląd odpowiedzi o nieobecności", + "body": "Widzi, czy automatyczna odpowiedź jest włączona i jaka jest jej treść." + }, + "vacation_write": { + "title": "Zmiana odpowiedzi o nieobecności", + "body": "Włącza i wyłącza automatyczną odpowiedź oraz zmienia jej treść i daty." + }, + "settings_read": { + "title": "Odczyt ustawień", + "body": "Widzi Twoje preferencje i definicje etykiet." + }, + "settings_write": { + "title": "Zmiana ustawień", + "body": "Dodaje etykiety, zmienia ich kolejność i zmienia Twoje preferencje." + }, + "security_read": { + "title": "Podgląd zabezpieczeń konta", + "body": "Widzi, czy uwierzytelnianie dwuskładnikowe i szyfrowanie są włączone, bez dostępu do sekretów." + }, + "auth_observe": { + "title": "Informacja o logowaniu", + "body": "Otrzymuje informację, gdy się logujesz, przełączasz konta lub Twoja sesja zostaje odnowiona." + }, + "auth_emit": { + "title": "Wylogowywanie", + "body": "Może wylogować Cię z webmaila." + }, + "account_read": { + "title": "Podgląd kont", + "body": "Widzi dodane przez Ciebie konta wraz z ich adresami i serwerami." + }, + "http_post": { + "title": "Korzystanie z serwera webmaila w Twoim imieniu", + "body": "Wywołuje te punkty końcowe serwera z Twoimi danymi logowania:" + }, + "http_fetch": { + "title": "Łączenie z innymi witrynami", + "body": "Wysyła żądania do tych witryn. Wszystko, co wtyczka może odczytać, może tam zostać wysłane:" + }, + "admin_config": { + "title": "Odczyt ustawień od administratora", + "body": "Odczytuje ustawienia, które administrator zapisał dla tej wtyczki." + }, + "ui_observe": { + "title": "Śledzenie, gdzie jesteś w aplikacji", + "body": "Wie, która strona jest otwarta, i reaguje na klikane linki." + }, + "ui_toolbar": { + "title": "Przyciski na pasku narzędzi wiadomości", + "body": "Pokazuje własne przyciski nad otwartą wiadomością." + }, + "ui_app_top_banner": { + "title": "Baner u góry", + "body": "Pokazuje komunikat na górze aplikacji." + }, + "ui_email_banner": { + "title": "Komunikaty przy wiadomościach", + "body": "Pokazuje komunikat nad treścią otwartej wiadomości." + }, + "ui_email_footer": { + "title": "Treść pod wiadomościami", + "body": "Pokazuje własną treść pod otwartą wiadomością." + }, + "ui_contact_cryptokeys": { + "title": "Klucze szyfrowania kontaktów", + "body": "Pokazuje klucze szyfrowania kontaktu i pozwala nimi zarządzać." + }, + "ui_email_details": { + "title": "Panele w szczegółach wiadomości", + "body": "Pokazuje własny panel obok lub pod otwartą wiadomością." + }, + "ui_download_file": { + "title": "Zapisywanie plików na urządzeniu", + "body": "Udostępnia do pobrania utworzone przez siebie pliki." + }, + "ui_message_list_tabs": { + "title": "Karty w Odebranych", + "body": "Dzieli Odebrane na karty, takie jak Aktualizacje czy Oferty." + }, + "ui_composer_toolbar": { + "title": "Przyciski w edytorze wiadomości", + "body": "Pokazuje własne przyciski podczas pisania wiadomości." + }, + "ui_composer_sidebar": { + "title": "Panel w edytorze wiadomości", + "body": "Pokazuje własny panel obok pisanej wiadomości." + }, + "ui_sidebar_widget": { + "title": "Pole na pasku bocznym", + "body": "Pokazuje własną treść na pasku bocznym poczty." + }, + "ui_settings_section": { + "title": "Strona ustawień", + "body": "Pokazuje własną stronę w Ustawieniach." + }, + "ui_context_menu": { + "title": "Pozycje w menu wiadomości", + "body": "Dodaje pozycje do menu otwieranego kliknięciem wiadomości prawym przyciskiem myszy." + }, + "ui_navigation_rail": { + "title": "Przycisk na pasku nawigacyjnym", + "body": "Pokazuje własny przycisk na pasku nawigacyjnym." + }, + "ui_keyboard": { + "title": "Skróty klawiszowe", + "body": "Reaguje na zdefiniowane przez siebie skróty klawiszowe." + }, + "ui_calendar_action": { + "title": "Przyciski przy wydarzeniach", + "body": "Pokazuje własne przyciski podczas tworzenia lub edytowania wydarzenia." + }, + "ui_admin_page": { + "title": "Strona administracyjna", + "body": "Pokazuje własną stronę w ustawieniach administracyjnych." + }, + "app_lifecycle": { + "title": "Informacja o uruchomieniu i zamknięciu aplikacji", + "body": "Otrzymuje informację, gdy aplikacja się uruchamia, przechodzi w tryb offline lub gdy się wylogowujesz." + } } }, "global_search": { diff --git a/locales/pt/common.json b/locales/pt/common.json index c7c935ba2..8d40cd3b1 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Falha na autorização: {error}", "missing_params": "Falta o código de autorização ou o estado.", "delivery_failed": "Não foi possível entregar o resultado da autorização ao separador da aplicação. Feche esta janela e tente novamente." + }, + "settings": { + "title": "Plugins", + "description": "Plugins adicionados pelo seu administrador. Ative ou desative-os na sua conta.", + "description_uploads": "Os plugins adicionam funções ao webmail. Ative ou desative-os na sua conta, ou instale os seus próprios.", + "description_demo": "Os plugins não estão disponíveis na demo.", + "install_button": "Instalar plugin", + "install_hint": "Escolha um arquivo ZIP de plugin.", + "install_hint_approval": "Escolha um arquivo ZIP de plugin. Um administrador precisa aprová-lo antes que você possa ativá-lo.", + "install_uploads_off": "Seu administrador não permite instalar plugins.", + "install_managed_id": "Seu administrador já fornece um plugin com este nome, por isso ele não pode ser substituído.", + "install_invalid": "Este arquivo não é um plugin que possa ser instalado: {details}", + "toast_installed": "Plugin instalado. Ative-o abaixo.", + "toast_installed_pending": "Plugin instalado e enviado ao seu administrador para aprovação.", + "toast_enabled": "{name} está ativado", + "toast_disabled": "{name} está desativado", + "toast_removed": "{name} foi removido", + "toast_forced": "Seu administrador mantém {name} ativado", + "toast_approval_requested": "{name} está aguardando a aprovação do seu administrador", + "empty_title": "Nenhum plugin ainda", + "empty_admin": "Seu administrador não adicionou nenhum plugin.", + "empty_uploads": "Instale um plugin a partir de um arquivo ZIP para começar.", + "empty_demo": "Os plugins precisam de uma conta real, por isso a demo não tem nenhum.", + "syncing": "Verificando plugins com o servidor…", + "status_installed": "Desativado", + "status_enabled": "Iniciando", + "status_running": "Ativado", + "status_disabled": "Desativado", + "status_error": "Problema", + "badge_forced": "Obrigatório", + "badge_managed": "Do seu administrador", + "badge_yours": "Instalado por você", + "badge_awaiting": "Aguardando aprovação", + "toggle_label": "Ativar ou desativar {name}", + "forced_note": "Seu administrador exige este plugin, por isso ele não pode ser desativado.", + "permissions": "O que ele pode fazer", + "permissions_managed": "O que ele pode fazer (aprovado pelo seu administrador)", + "settings": "Configurações", + "remove": "Remover", + "remove_title": "Remover {name}?", + "remove_message": "O plugin e tudo o que ele armazenou neste navegador serão excluídos. Você pode instalá-lo novamente depois.", + "remove_confirm": "Remover", + "error_user_plugins_off": "Seu administrador desativou os plugins instalados por usuários, por isso este plugin não é executado.", + "error_approval_unavailable": "Este plugin foi instalado antes de as aprovações serem exigidas. Remova-o e instale-o novamente para enviá-lo para aprovação.", + "error_approval_denied": "Seu administrador não aprovou este plugin.", + "error_approval_pending": "Aguardando o seu administrador aprovar este plugin." + }, + "consent": { + "title": "Permitir que {name} faça isto?", + "intro": "O plugin pede as permissões abaixo. Ele só as recebe se você permitir.", + "high_risk_note": "Os itens marcados dão ao plugin acesso ao conteúdo dos seus e-mails ou permitem que ele envie dados para outro lugar. Só os permita para plugins em que você confia.", + "revoke_hint": "Você pode desativar ou remover o plugin em Configurações a qualquer momento.", + "deny": "Não permitir", + "allow": "Permitir" + }, + "permissions": { + "none": "Não precisa de permissões especiais.", + "high_risk": "Requer atenção", + "email_read": { + "title": "Ler seus e-mails", + "body": "Ver remetentes, destinatários, assuntos e o texto completo das mensagens que você abre e da lista de mensagens." + }, + "email_write": { + "title": "Alterar etiquetas e marcações dos seus e-mails", + "body": "Adicionar ou remover etiquetas, marcações e outras palavras-chave nas suas mensagens." + }, + "email_send": { + "title": "Alterar e-mails antes do envio", + "body": "Ler e alterar os destinatários, o assunto, o texto e os anexos das mensagens que você escreve, e impedir o envio de uma mensagem." + }, + "crypto_full": { + "title": "Acesso total a este aplicativo", + "body": "É executado com os mesmos direitos do próprio webmail. Pode ler todos os seus e-mails e chaves armazenadas e agir em seu nome. Só permita isto para plugins em que você confia totalmente." + }, + "email_raw_send": { + "title": "Enviar mensagens criadas por ele mesmo", + "body": "Enviar mensagens completas, por exemplo assinadas ou criptografadas, a partir da sua conta." + }, + "email_blob_read": { + "title": "Ler o conteúdo bruto das mensagens", + "body": "Ler os dados originais das suas mensagens e anexos, e de cada arquivo que você anexa antes do envio." + }, + "email_blob_write": { + "title": "Substituir anexos antes do envio", + "body": "Enviar arquivos para o seu servidor de e-mail e substituir um arquivo anexado por outro antes de a mensagem ser enviada." + }, + "email_render_takeover": { + "title": "Alterar como as mensagens são exibidas", + "body": "Substituir o texto das mensagens que você abre e da lista de mensagens, por exemplo para mostrar conteúdo descriptografado." + }, + "calendar_read": { + "title": "Ler seu calendário", + "body": "Ver seus calendários e os eventos deles." + }, + "calendar_write": { + "title": "Alterar seu calendário", + "body": "Criar, alterar e excluir eventos." + }, + "contacts_read": { + "title": "Ler seus contatos", + "body": "Pesquisar e ler seus catálogos de endereços." + }, + "contacts_write": { + "title": "Alterar seus contatos", + "body": "Criar, alterar e excluir contatos e catálogos de endereços." + }, + "files_read": { + "title": "Ler seus arquivos", + "body": "Navegar e abrir os arquivos armazenados na sua conta." + }, + "files_write": { + "title": "Alterar seus arquivos", + "body": "Enviar, renomear, mover e excluir arquivos na sua conta." + }, + "identity_read": { + "title": "Ver suas identidades de envio", + "body": "Ver os endereços dos quais você envia e as assinaturas deles." + }, + "identity_write": { + "title": "Alterar suas identidades de envio", + "body": "Criar, alterar e excluir identidades de envio e assinaturas." + }, + "filters_read": { + "title": "Ler seus filtros de e-mail", + "body": "Ver as regras que organizam seus e-mails recebidos." + }, + "filters_write": { + "title": "Alterar seus filtros de e-mail", + "body": "Adicionar regras aos filtros do servidor. Essas regras podem mover, encaminhar ou excluir e-mails recebidos." + }, + "tasks_read": { + "title": "Ler suas tarefas", + "body": "Ver suas listas de tarefas e tarefas." + }, + "tasks_write": { + "title": "Alterar suas tarefas", + "body": "Criar, alterar, concluir e excluir tarefas." + }, + "templates_read": { + "title": "Ler seus modelos", + "body": "Ver seus modelos de mensagem salvos." + }, + "templates_write": { + "title": "Alterar seus modelos", + "body": "Criar, alterar e excluir modelos de mensagem." + }, + "smime_read": { + "title": "Ver seu status S/MIME", + "body": "Ver se você tem chaves e certificados S/MIME, sem acesso às chaves." + }, + "vacation_read": { + "title": "Ver sua resposta automática", + "body": "Ver se sua resposta automática está ativada e o que ela diz." + }, + "vacation_write": { + "title": "Alterar sua resposta automática", + "body": "Ativar ou desativar sua resposta automática e alterar o texto e as datas." + }, + "settings_read": { + "title": "Ler suas configurações", + "body": "Ver suas preferências e definições de etiquetas." + }, + "settings_write": { + "title": "Alterar suas configurações", + "body": "Adicionar e reordenar etiquetas e alterar suas preferências." + }, + "security_read": { + "title": "Ver a segurança da sua conta", + "body": "Ver se a autenticação de dois fatores e a criptografia estão ativadas, sem acesso a segredos." + }, + "auth_observe": { + "title": "Saber quando você entra", + "body": "Ser avisado quando você entra, troca de conta ou sua sessão é renovada." + }, + "auth_emit": { + "title": "Encerrar sua sessão", + "body": "Encerrar sua sessão no webmail." + }, + "account_read": { + "title": "Ver suas contas", + "body": "Ver as contas que você adicionou, com os endereços e servidores delas." + }, + "http_post": { + "title": "Usar o servidor do webmail por você", + "body": "Chamar estes endpoints do servidor com o seu login:" + }, + "http_fetch": { + "title": "Conectar-se a outros sites", + "body": "Enviar solicitações a estes sites. Tudo o que o plugin pode ler pode ser enviado para lá:" + }, + "admin_config": { + "title": "Ler as configurações do seu administrador", + "body": "Ler as configurações que seu administrador salvou para este plugin." + }, + "ui_observe": { + "title": "Acompanhar onde você está no aplicativo", + "body": "Saber qual página está aberta e reagir aos links em que você clica." + }, + "ui_toolbar": { + "title": "Adicionar botões à barra de ferramentas da mensagem", + "body": "Mostrar os próprios botões acima de uma mensagem aberta." + }, + "ui_app_top_banner": { + "title": "Mostrar um banner no topo", + "body": "Mostrar um aviso no topo do aplicativo." + }, + "ui_email_banner": { + "title": "Mostrar avisos nas mensagens", + "body": "Mostrar um aviso acima do texto de uma mensagem aberta." + }, + "ui_email_footer": { + "title": "Adicionar conteúdo abaixo das mensagens", + "body": "Mostrar o próprio conteúdo abaixo de uma mensagem aberta." + }, + "ui_contact_cryptokeys": { + "title": "Mostrar chaves de criptografia dos contatos", + "body": "Mostrar e gerenciar as chaves de criptografia de um contato." + }, + "ui_email_details": { + "title": "Adicionar painéis aos detalhes da mensagem", + "body": "Mostrar o próprio painel ao lado ou abaixo de uma mensagem aberta." + }, + "ui_download_file": { + "title": "Salvar arquivos no seu dispositivo", + "body": "Oferecer para download arquivos que ele criou." + }, + "ui_message_list_tabs": { + "title": "Adicionar abas à sua caixa de entrada", + "body": "Organizar sua caixa de entrada em abas como Atualizações ou Promoções." + }, + "ui_composer_toolbar": { + "title": "Adicionar botões ao editor", + "body": "Mostrar os próprios botões enquanto você escreve uma mensagem." + }, + "ui_composer_sidebar": { + "title": "Adicionar um painel ao editor", + "body": "Mostrar o próprio painel ao lado da mensagem que você escreve." + }, + "ui_sidebar_widget": { + "title": "Adicionar uma caixa à barra lateral", + "body": "Mostrar o próprio conteúdo na barra lateral do e-mail." + }, + "ui_settings_section": { + "title": "Adicionar uma página de configurações", + "body": "Mostrar a própria página em Configurações." + }, + "ui_context_menu": { + "title": "Adicionar itens ao menu da mensagem", + "body": "Adicionar itens ao menu que abre quando você clica com o botão direito em uma mensagem." + }, + "ui_navigation_rail": { + "title": "Adicionar à barra de navegação", + "body": "Mostrar o próprio botão na barra de navegação." + }, + "ui_keyboard": { + "title": "Adicionar atalhos de teclado", + "body": "Reagir aos atalhos de teclado que ele define." + }, + "ui_calendar_action": { + "title": "Adicionar botões aos eventos", + "body": "Mostrar os próprios botões quando você cria ou edita um evento." + }, + "ui_admin_page": { + "title": "Adicionar uma página de administração", + "body": "Mostrar a própria página nas configurações de administração." + }, + "app_lifecycle": { + "title": "Saber quando o aplicativo inicia e para", + "body": "Ser avisado quando o aplicativo inicia, fica offline ou você sai." + } } }, "global_search": { diff --git a/locales/ro/common.json b/locales/ro/common.json index f49207dd9..9aed8133b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorizarea a eșuat: {error}", "missing_params": "Lipsește codul de autorizare sau starea.", "delivery_failed": "Rezultatul autorizării nu a putut fi transmis filei aplicației. Închideți această fereastră și încercați din nou." + }, + "settings": { + "title": "Pluginuri", + "description": "Pluginuri adăugate de administrator. Activați-le sau dezactivați-le pentru contul dvs.", + "description_uploads": "Pluginurile adaugă funcții în webmail. Activați-le sau dezactivați-le pentru contul dvs. sau instalați-vă propriile pluginuri.", + "description_demo": "Pluginurile nu sunt disponibile în demo.", + "install_button": "Instalați un plugin", + "install_hint": "Alegeți un fișier ZIP cu un plugin.", + "install_hint_approval": "Alegeți un fișier ZIP cu un plugin. Un administrator trebuie să-l aprobe înainte să-l puteți activa.", + "install_uploads_off": "Administratorul dvs. nu permite instalarea pluginurilor.", + "install_managed_id": "Administratorul dvs. oferă deja un plugin cu acest nume, așa că nu poate fi înlocuit.", + "install_invalid": "Acest fișier nu este un plugin care poate fi instalat: {details}", + "toast_installed": "Pluginul a fost instalat. Activați-l mai jos.", + "toast_installed_pending": "Pluginul a fost instalat și trimis administratorului spre aprobare.", + "toast_enabled": "{name} este activat", + "toast_disabled": "{name} este dezactivat", + "toast_removed": "{name} a fost eliminat", + "toast_forced": "Administratorul dvs. menține {name} activat", + "toast_approval_requested": "{name} așteaptă aprobarea administratorului", + "empty_title": "Niciun plugin încă", + "empty_admin": "Administratorul dvs. nu a adăugat niciun plugin.", + "empty_uploads": "Pentru a începe, instalați un plugin dintr-un fișier ZIP.", + "empty_demo": "Pluginurile au nevoie de un cont real, așa că demo-ul nu are niciunul.", + "syncing": "Se verifică pluginurile cu serverul…", + "status_installed": "Dezactivat", + "status_enabled": "Se pornește", + "status_running": "Activat", + "status_disabled": "Dezactivat", + "status_error": "Problemă", + "badge_forced": "Obligatoriu", + "badge_managed": "De la administrator", + "badge_yours": "Instalat de dvs.", + "badge_awaiting": "Așteaptă aprobarea", + "toggle_label": "Activați sau dezactivați {name}", + "forced_note": "Administratorul dvs. impune acest plugin, așa că nu poate fi dezactivat.", + "permissions": "Ce poate face", + "permissions_managed": "Ce poate face (aprobat de administrator)", + "settings": "Setări", + "remove": "Eliminați", + "remove_title": "Eliminați {name}?", + "remove_message": "Pluginul și tot ce a stocat în acest browser vor fi șterse. Îl puteți instala din nou mai târziu.", + "remove_confirm": "Eliminați", + "error_user_plugins_off": "Administratorul dvs. a dezactivat pluginurile instalate de utilizatori, așa că acest plugin nu rulează.", + "error_approval_unavailable": "Acest plugin a fost instalat înainte ca aprobările să fie obligatorii. Eliminați-l și instalați-l din nou pentru a-l trimite spre aprobare.", + "error_approval_denied": "Administratorul dvs. nu a aprobat acest plugin.", + "error_approval_pending": "Se așteaptă ca administratorul să aprobe acest plugin." + }, + "consent": { + "title": "Permiteți ca {name} să facă acest lucru?", + "intro": "Pluginul cere permisiunile de mai jos. Le primește doar dacă le permiteți.", + "high_risk_note": "Elementele marcate oferă pluginului acces la conținutul e-mailurilor dvs. sau îi permit să trimită date în altă parte. Permiteți-le doar pluginurilor în care aveți încredere.", + "revoke_hint": "Puteți dezactiva sau elimina pluginul oricând din Setări.", + "deny": "Nu permiteți", + "allow": "Permiteți" + }, + "permissions": { + "none": "Nu are nevoie de permisiuni speciale.", + "high_risk": "Necesită atenție", + "email_read": { + "title": "Citirea e-mailurilor", + "body": "Vede expeditorii, destinatarii, subiectele și textul complet al mesajelor pe care le deschideți și al listei de mesaje." + }, + "email_write": { + "title": "Modificarea etichetelor și marcajelor e-mailurilor", + "body": "Adaugă sau elimină etichete, marcaje și alte cuvinte cheie din mesajele dvs." + }, + "email_send": { + "title": "Modificarea e-mailurilor înainte de trimitere", + "body": "Citește și modifică destinatarii, subiectul, textul și atașamentele mesajelor pe care le scrieți și poate opri trimiterea unui mesaj." + }, + "crypto_full": { + "title": "Acces complet la această aplicație", + "body": "Rulează cu aceleași drepturi ca webmailul însuși. Poate citi toate e-mailurile și cheile stocate și poate acționa în numele dvs. Permiteți acest lucru doar pluginurilor în care aveți încredere deplină." + }, + "email_raw_send": { + "title": "Trimiterea mesajelor create de el însuși", + "body": "Trimite mesaje complete, de exemplu semnate sau criptate, din contul dvs." + }, + "email_blob_read": { + "title": "Citirea conținutului brut al mesajelor", + "body": "Citește datele originale ale mesajelor și atașamentelor dvs. și ale fiecărui fișier pe care îl atașați înainte de trimitere." + }, + "email_blob_write": { + "title": "Înlocuirea atașamentelor înainte de trimitere", + "body": "Încarcă fișiere pe serverul dvs. de e-mail și înlocuiește un fișier atașat cu altul înainte ca mesajul să fie trimis." + }, + "email_render_takeover": { + "title": "Modificarea modului în care sunt afișate mesajele", + "body": "Înlocuiește textul mesajelor pe care le deschideți și al listei de mesaje, de exemplu pentru a afișa conținut decriptat." + }, + "calendar_read": { + "title": "Citirea calendarului", + "body": "Vede calendarele dvs. și evenimentele lor." + }, + "calendar_write": { + "title": "Modificarea calendarului", + "body": "Creează, modifică și șterge evenimente." + }, + "contacts_read": { + "title": "Citirea contactelor", + "body": "Caută în agendele dvs. și le citește." + }, + "contacts_write": { + "title": "Modificarea contactelor", + "body": "Creează, modifică și șterge contacte și agende." + }, + "files_read": { + "title": "Citirea fișierelor", + "body": "Răsfoiește și deschide fișierele stocate în contul dvs." + }, + "files_write": { + "title": "Modificarea fișierelor", + "body": "Încarcă, redenumește, mută și șterge fișiere din contul dvs." + }, + "identity_read": { + "title": "Vizualizarea identităților de expediere", + "body": "Vede adresele de pe care trimiteți și semnăturile lor." + }, + "identity_write": { + "title": "Modificarea identităților de expediere", + "body": "Creează, modifică și șterge identități de expediere și semnături." + }, + "filters_read": { + "title": "Citirea filtrelor de e-mail", + "body": "Vede regulile care sortează e-mailurile primite." + }, + "filters_write": { + "title": "Modificarea filtrelor de e-mail", + "body": "Adaugă reguli în filtrele de pe server. Aceste reguli pot muta, redirecționa sau șterge e-mailurile primite." + }, + "tasks_read": { + "title": "Citirea sarcinilor", + "body": "Vede listele de sarcini și sarcinile dvs." + }, + "tasks_write": { + "title": "Modificarea sarcinilor", + "body": "Creează, modifică, finalizează și șterge sarcini." + }, + "templates_read": { + "title": "Citirea șabloanelor", + "body": "Vede șabloanele de mesaje salvate." + }, + "templates_write": { + "title": "Modificarea șabloanelor", + "body": "Creează, modifică și șterge șabloane de mesaje." + }, + "smime_read": { + "title": "Vizualizarea stării S/MIME", + "body": "Vede dacă aveți chei și certificate S/MIME, fără acces la chei." + }, + "vacation_read": { + "title": "Vizualizarea răspunsului automat", + "body": "Vede dacă răspunsul automat este activat și ce conține." + }, + "vacation_write": { + "title": "Modificarea răspunsului automat", + "body": "Activează sau dezactivează răspunsul automat și îi modifică textul și datele." + }, + "settings_read": { + "title": "Citirea setărilor", + "body": "Vede preferințele dvs. și definițiile etichetelor." + }, + "settings_write": { + "title": "Modificarea setărilor", + "body": "Adaugă și reordonează etichete și modifică preferințele dvs." + }, + "security_read": { + "title": "Vizualizarea securității contului", + "body": "Vede dacă autentificarea în doi pași și criptarea sunt activate, fără acces la secrete." + }, + "auth_observe": { + "title": "Aflarea momentului autentificării", + "body": "Este anunțat când vă autentificați, schimbați contul sau sesiunea este reînnoită." + }, + "auth_emit": { + "title": "Deconectarea dvs.", + "body": "Vă deconectează din webmail." + }, + "account_read": { + "title": "Vizualizarea conturilor", + "body": "Vede conturile pe care le-ați adăugat, cu adresele și serverele lor." + }, + "http_post": { + "title": "Folosirea serverului webmail în numele dvs.", + "body": "Apelează aceste endpointuri ale serverului cu datele dvs. de autentificare:" + }, + "http_fetch": { + "title": "Conectarea la alte site-uri", + "body": "Trimite cereri către aceste site-uri. Orice poate citi pluginul poate fi trimis acolo:" + }, + "admin_config": { + "title": "Citirea setărilor de la administrator", + "body": "Citește setările pe care administratorul le-a salvat pentru acest plugin." + }, + "ui_observe": { + "title": "Urmărirea locului în care vă aflați în aplicație", + "body": "Știe ce pagină este deschisă și reacționează la linkurile pe care faceți clic." + }, + "ui_toolbar": { + "title": "Adăugarea de butoane în bara de instrumente a mesajului", + "body": "Afișează butoane proprii deasupra unui mesaj deschis." + }, + "ui_app_top_banner": { + "title": "Afișarea unui banner în partea de sus", + "body": "Afișează o notificare în partea de sus a aplicației." + }, + "ui_email_banner": { + "title": "Afișarea de notificări pe mesaje", + "body": "Afișează o notificare deasupra textului unui mesaj deschis." + }, + "ui_email_footer": { + "title": "Adăugarea de conținut sub mesaje", + "body": "Afișează conținut propriu sub un mesaj deschis." + }, + "ui_contact_cryptokeys": { + "title": "Afișarea cheilor de criptare ale contactelor", + "body": "Afișează și gestionează cheile de criptare ale unui contact." + }, + "ui_email_details": { + "title": "Adăugarea de panouri la detaliile mesajului", + "body": "Afișează un panou propriu lângă sau sub un mesaj deschis." + }, + "ui_download_file": { + "title": "Salvarea fișierelor pe dispozitiv", + "body": "Oferă spre descărcare fișierele create de el." + }, + "ui_message_list_tabs": { + "title": "Adăugarea de file în Primite", + "body": "Sortează mesajele primite în file precum Actualizări sau Promoții." + }, + "ui_composer_toolbar": { + "title": "Adăugarea de butoane în fereastra de redactare", + "body": "Afișează butoane proprii în timp ce scrieți un mesaj." + }, + "ui_composer_sidebar": { + "title": "Adăugarea unui panou în fereastra de redactare", + "body": "Afișează un panou propriu lângă mesajul pe care îl scrieți." + }, + "ui_sidebar_widget": { + "title": "Adăugarea unei casete în bara laterală", + "body": "Afișează conținut propriu în bara laterală a e-mailului." + }, + "ui_settings_section": { + "title": "Adăugarea unei pagini de setări", + "body": "Afișează o pagină proprie în Setări." + }, + "ui_context_menu": { + "title": "Adăugarea de opțiuni în meniul mesajului", + "body": "Adaugă opțiuni în meniul care se deschide când faceți clic dreapta pe un mesaj." + }, + "ui_navigation_rail": { + "title": "Adăugarea în bara de navigare", + "body": "Afișează un buton propriu în bara de navigare." + }, + "ui_keyboard": { + "title": "Adăugarea de comenzi rapide de la tastatură", + "body": "Reacționează la comenzile rapide de la tastatură pe care le definește." + }, + "ui_calendar_action": { + "title": "Adăugarea de butoane la evenimente", + "body": "Afișează butoane proprii când creați sau editați un eveniment." + }, + "ui_admin_page": { + "title": "Adăugarea unei pagini de administrare", + "body": "Afișează o pagină proprie în setările de administrare." + }, + "app_lifecycle": { + "title": "Aflarea momentului pornirii și opririi aplicației", + "body": "Este anunțat când aplicația pornește, trece offline sau vă deconectați." + } } }, "global_search": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 0a9011a90..985279c82 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Ошибка авторизации: {error}", "missing_params": "Отсутствует код авторизации или состояние.", "delivery_failed": "Не удалось передать результат авторизации на вкладку приложения. Закройте это окно и попробуйте снова." + }, + "settings": { + "title": "Плагины", + "description": "Плагины, добавленные администратором. Включайте и выключайте их для своего аккаунта.", + "description_uploads": "Плагины добавляют в webmail новые функции. Включайте и выключайте их для своего аккаунта или устанавливайте свои.", + "description_demo": "В демо-режиме плагины недоступны.", + "install_button": "Установить плагин", + "install_hint": "Выберите ZIP-файл плагина.", + "install_hint_approval": "Выберите ZIP-файл плагина. Прежде чем вы сможете его включить, его должен одобрить администратор.", + "install_uploads_off": "Администратор не разрешает устанавливать плагины.", + "install_managed_id": "Администратор уже предоставляет плагин с таким именем, поэтому его нельзя заменить.", + "install_invalid": "Этот файл не является плагином, который можно установить: {details}", + "toast_installed": "Плагин установлен. Включите его ниже.", + "toast_installed_pending": "Плагин установлен и отправлен администратору на одобрение.", + "toast_enabled": "Плагин {name} включён", + "toast_disabled": "Плагин {name} выключен", + "toast_removed": "Плагин {name} удалён", + "toast_forced": "Администратор требует, чтобы плагин {name} оставался включённым", + "toast_approval_requested": "Плагин {name} ожидает одобрения администратором", + "empty_title": "Плагинов пока нет", + "empty_admin": "Администратор не добавил ни одного плагина.", + "empty_uploads": "Чтобы начать, установите плагин из ZIP-файла.", + "empty_demo": "Плагинам нужен настоящий аккаунт, поэтому в демо-режиме их нет.", + "syncing": "Проверка плагинов на сервере…", + "status_installed": "Выключен", + "status_enabled": "Запускается", + "status_running": "Включён", + "status_disabled": "Выключен", + "status_error": "Ошибка", + "badge_forced": "Обязательный", + "badge_managed": "От администратора", + "badge_yours": "Установлен вами", + "badge_awaiting": "Ожидает одобрения", + "toggle_label": "Включить или выключить плагин {name}", + "forced_note": "Администратор требует этот плагин, поэтому его нельзя выключить.", + "permissions": "Что он может делать", + "permissions_managed": "Что он может делать (одобрено администратором)", + "settings": "Настройки", + "remove": "Удалить", + "remove_title": "Удалить плагин {name}?", + "remove_message": "Плагин и все данные, которые он сохранил в этом браузере, будут удалены. Позже его можно установить снова.", + "remove_confirm": "Удалить", + "error_user_plugins_off": "Администратор отключил плагины, установленные пользователями, поэтому этот плагин не работает.", + "error_approval_unavailable": "Этот плагин был установлен до того, как появилось одобрение. Удалите и установите его снова, чтобы отправить на одобрение.", + "error_approval_denied": "Администратор не одобрил этот плагин.", + "error_approval_pending": "Ожидается одобрение этого плагина администратором." + }, + "consent": { + "title": "Разрешить плагину {name} это?", + "intro": "Плагин запрашивает разрешения, перечисленные ниже. Он получит их, только если вы разрешите.", + "high_risk_note": "Отмеченные пункты дают плагину доступ к содержимому вашей почты или позволяют ему передавать данные на сторону. Разрешайте их только плагинам, которым доверяете.", + "revoke_hint": "Вы в любой момент можете выключить или удалить плагин в настройках.", + "deny": "Не разрешать", + "allow": "Разрешить" + }, + "permissions": { + "none": "Ему не нужны особые разрешения.", + "high_risk": "Требует осторожности", + "email_read": { + "title": "Читать вашу почту", + "body": "Видеть отправителей, получателей, темы и полный текст открытых писем и писем в списке сообщений." + }, + "email_write": { + "title": "Менять теги и флажки писем", + "body": "Добавлять и удалять теги, флажки и другие ключевые слова у ваших писем." + }, + "email_send": { + "title": "Менять письма перед отправкой", + "body": "Читать и менять получателей, тему, текст и вложения писем, которые вы пишете, а также останавливать отправку письма." + }, + "crypto_full": { + "title": "Полный доступ к приложению", + "body": "Работает с теми же правами, что и сам webmail. Может читать всю вашу почту и сохранённые ключи и действовать от вашего имени. Разрешайте это только плагинам, которым полностью доверяете." + }, + "email_raw_send": { + "title": "Отправлять собственные письма", + "body": "Отправлять с вашего аккаунта готовые письма, например подписанные или зашифрованные." + }, + "email_blob_read": { + "title": "Читать исходные данные писем", + "body": "Читать исходные данные ваших писем и вложений, а также каждого файла, который вы прикрепляете, до отправки." + }, + "email_blob_write": { + "title": "Заменять вложения перед отправкой", + "body": "Загружать файлы на ваш почтовый сервер и заменять прикреплённый файл другим до отправки письма." + }, + "email_render_takeover": { + "title": "Менять отображение писем", + "body": "Заменять текст открытых писем и писем в списке сообщений, например чтобы показать расшифрованное содержимое." + }, + "calendar_read": { + "title": "Читать ваш календарь", + "body": "Видеть ваши календари и их события." + }, + "calendar_write": { + "title": "Менять ваш календарь", + "body": "Создавать, менять и удалять события." + }, + "contacts_read": { + "title": "Читать ваши контакты", + "body": "Искать в ваших адресных книгах и читать их." + }, + "contacts_write": { + "title": "Менять ваши контакты", + "body": "Создавать, менять и удалять контакты и адресные книги." + }, + "files_read": { + "title": "Читать ваши файлы", + "body": "Просматривать и открывать файлы, хранящиеся в вашем аккаунте." + }, + "files_write": { + "title": "Менять ваши файлы", + "body": "Загружать, переименовывать, перемещать и удалять файлы в вашем аккаунте." + }, + "identity_read": { + "title": "Видеть ваши адреса отправителя", + "body": "Видеть адреса, с которых вы отправляете письма, и их подписи." + }, + "identity_write": { + "title": "Менять ваши адреса отправителя", + "body": "Создавать, менять и удалять адреса отправителя и подписи." + }, + "filters_read": { + "title": "Читать ваши фильтры почты", + "body": "Видеть правила, которые сортируют входящую почту." + }, + "filters_write": { + "title": "Менять ваши фильтры почты", + "body": "Добавлять правила в фильтры на сервере. Такие правила могут перемещать, пересылать или удалять входящую почту." + }, + "tasks_read": { + "title": "Читать ваши задачи", + "body": "Видеть ваши списки задач и задачи." + }, + "tasks_write": { + "title": "Менять ваши задачи", + "body": "Создавать, менять, завершать и удалять задачи." + }, + "templates_read": { + "title": "Читать ваши шаблоны", + "body": "Видеть сохранённые шаблоны писем." + }, + "templates_write": { + "title": "Менять ваши шаблоны", + "body": "Создавать, менять и удалять шаблоны писем." + }, + "smime_read": { + "title": "Видеть ваш статус S/MIME", + "body": "Видеть, есть ли у вас ключи и сертификаты S/MIME, без доступа к самим ключам." + }, + "vacation_read": { + "title": "Видеть ваш автоответ", + "body": "Видеть, включён ли автоответ и что в нём написано." + }, + "vacation_write": { + "title": "Менять ваш автоответ", + "body": "Включать и выключать автоответ, менять его текст и даты." + }, + "settings_read": { + "title": "Читать ваши настройки", + "body": "Видеть ваши параметры и определения тегов." + }, + "settings_write": { + "title": "Менять ваши настройки", + "body": "Добавлять теги, менять их порядок и менять ваши параметры." + }, + "security_read": { + "title": "Видеть безопасность аккаунта", + "body": "Видеть, включены ли двухфакторная аутентификация и шифрование, без доступа к секретам." + }, + "auth_observe": { + "title": "Узнавать о вашем входе", + "body": "Получать уведомления, когда вы входите, переключаете аккаунт или ваш сеанс продлевается." + }, + "auth_emit": { + "title": "Выполнять выход", + "body": "Выполнять ваш выход из webmail." + }, + "account_read": { + "title": "Видеть ваши аккаунты", + "body": "Видеть добавленные вами аккаунты, их адреса и серверы." + }, + "http_post": { + "title": "Обращаться к серверу webmail от вашего имени", + "body": "Вызывать эти адреса сервера с вашими учётными данными:" + }, + "http_fetch": { + "title": "Подключаться к другим сайтам", + "body": "Отправлять запросы на эти сайты. Туда может быть отправлено всё, что плагин может прочитать:" + }, + "admin_config": { + "title": "Читать свои настройки от администратора", + "body": "Читать настройки, которые администратор сохранил для этого плагина." + }, + "ui_observe": { + "title": "Следить за тем, где вы в приложении", + "body": "Знать, какая страница открыта, и реагировать на ссылки, по которым вы нажимаете." + }, + "ui_toolbar": { + "title": "Добавлять кнопки на панель инструментов письма", + "body": "Показывать свои кнопки над открытым письмом." + }, + "ui_app_top_banner": { + "title": "Показывать баннер сверху", + "body": "Показывать уведомление в верхней части приложения." + }, + "ui_email_banner": { + "title": "Показывать уведомления в письмах", + "body": "Показывать уведомление над текстом открытого письма." + }, + "ui_email_footer": { + "title": "Добавлять содержимое под письмами", + "body": "Показывать своё содержимое под открытым письмом." + }, + "ui_contact_cryptokeys": { + "title": "Показывать ключи шифрования контактов", + "body": "Показывать ключи шифрования контакта и управлять ими." + }, + "ui_email_details": { + "title": "Добавлять панели к письму", + "body": "Показывать свою панель рядом с открытым письмом или под ним." + }, + "ui_download_file": { + "title": "Сохранять файлы на ваше устройство", + "body": "Предлагать для скачивания созданные им файлы." + }, + "ui_message_list_tabs": { + "title": "Добавлять вкладки во «Входящие»", + "body": "Распределять входящие письма по вкладкам, например «Обновления» или «Промоакции»." + }, + "ui_composer_toolbar": { + "title": "Добавлять кнопки в редактор", + "body": "Показывать свои кнопки, пока вы пишете письмо." + }, + "ui_composer_sidebar": { + "title": "Добавлять панель в редактор", + "body": "Показывать свою панель рядом с письмом, которое вы пишете." + }, + "ui_sidebar_widget": { + "title": "Добавлять блок на боковую панель", + "body": "Показывать своё содержимое на боковой панели почты." + }, + "ui_settings_section": { + "title": "Добавлять страницу настроек", + "body": "Показывать свою страницу в настройках." + }, + "ui_context_menu": { + "title": "Добавлять пункты в меню письма", + "body": "Добавлять пункты в меню, которое открывается по правому щелчку на письме." + }, + "ui_navigation_rail": { + "title": "Добавлять кнопку на панель навигации", + "body": "Показывать свою кнопку на панели навигации." + }, + "ui_keyboard": { + "title": "Добавлять сочетания клавиш", + "body": "Реагировать на заданные им сочетания клавиш." + }, + "ui_calendar_action": { + "title": "Добавлять кнопки к событиям", + "body": "Показывать свои кнопки, когда вы создаёте или редактируете событие." + }, + "ui_admin_page": { + "title": "Добавлять страницу администрирования", + "body": "Показывать свою страницу в настройках администратора." + }, + "app_lifecycle": { + "title": "Узнавать о запуске и остановке приложения", + "body": "Получать уведомления, когда приложение запускается, теряет связь или вы выходите." + } } }, "global_search": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 088f7da76..3e87836d8 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Autorizácia zlyhala: {error}", "missing_params": "Chýba autorizačný kód alebo stav.", "delivery_failed": "Výsledok autorizácie sa nepodarilo odovzdať karte aplikácie. Zavrite toto okno a skúste to znova." + }, + "settings": { + "title": "Doplnky", + "description": "Doplnky pridané vaším správcom. Pre svoj účet ich môžete zapnúť alebo vypnúť.", + "description_uploads": "Doplnky pridávajú do webmailu ďalšie funkcie. Pre svoj účet ich môžete zapnúť alebo vypnúť, prípadne nainštalovať vlastné.", + "description_demo": "Doplnky nie sú v ukážkovej verzii k dispozícii.", + "install_button": "Nainštalovať doplnok", + "install_hint": "Vyberte súbor ZIP s doplnkom.", + "install_hint_approval": "Vyberte súbor ZIP s doplnkom. Skôr než ho budete môcť zapnúť, musí ho schváliť správca.", + "install_uploads_off": "Váš správca nepovoľuje inštaláciu doplnkov.", + "install_managed_id": "Váš správca už poskytuje doplnok s týmto názvom, preto ho nemožno nahradiť.", + "install_invalid": "Tento súbor nie je doplnok, ktorý možno nainštalovať: {details}", + "toast_installed": "Doplnok je nainštalovaný. Zapnite ho nižšie.", + "toast_installed_pending": "Doplnok je nainštalovaný a odoslaný správcovi na schválenie.", + "toast_enabled": "Doplnok {name} je zapnutý", + "toast_disabled": "Doplnok {name} je vypnutý", + "toast_removed": "Doplnok {name} bol odstránený", + "toast_forced": "Váš správca ponecháva doplnok {name} zapnutý", + "toast_approval_requested": "Doplnok {name} čaká na schválenie správcom", + "empty_title": "Zatiaľ žiadne doplnky", + "empty_admin": "Váš správca zatiaľ nepridal žiadne doplnky.", + "empty_uploads": "Začnite inštaláciou doplnku zo súboru ZIP.", + "empty_demo": "Doplnky vyžadujú skutočný účet, preto ich ukážková verzia nemá.", + "syncing": "Kontrola doplnkov na serveri…", + "status_installed": "Vypnuté", + "status_enabled": "Spúšťa sa", + "status_running": "Zapnuté", + "status_disabled": "Vypnuté", + "status_error": "Problém", + "badge_forced": "Povinný", + "badge_managed": "Od správcu", + "badge_yours": "Nainštalované vami", + "badge_awaiting": "Čaká na schválenie", + "toggle_label": "Zapnúť alebo vypnúť doplnok {name}", + "forced_note": "Váš správca tento doplnok vyžaduje, preto ho nemožno vypnúť.", + "permissions": "Čo môže robiť", + "permissions_managed": "Čo môže robiť (schválené správcom)", + "settings": "Nastavenia", + "remove": "Odstrániť", + "remove_title": "Odstrániť doplnok {name}?", + "remove_message": "Doplnok aj všetko, čo uložil v tomto prehliadači, sa vymaže. Neskôr ho môžete nainštalovať znova.", + "remove_confirm": "Odstrániť", + "error_user_plugins_off": "Váš správca vypol doplnky inštalované používateľmi, preto tento doplnok nebeží.", + "error_approval_unavailable": "Tento doplnok bol nainštalovaný skôr, než sa vyžadovalo schválenie. Odstráňte ho a nainštalujte znova, aby sa odoslal na schválenie.", + "error_approval_denied": "Váš správca tento doplnok neschválil.", + "error_approval_pending": "Čaká sa, kým správca tento doplnok schváli." + }, + "consent": { + "title": "Povoliť doplnku {name} tieto akcie?", + "intro": "Doplnok žiada o nižšie uvedené oprávnenia. Dostane ich, len ak ich povolíte.", + "high_risk_note": "Označené položky dávajú doplnku prístup k obsahu vašej pošty alebo mu umožňujú posielať údaje inam. Povoľte ich len doplnkom, ktorým dôverujete.", + "revoke_hint": "Doplnok môžete kedykoľvek vypnúť alebo odstrániť v Nastaveniach.", + "deny": "Nepovoliť", + "allow": "Povoliť" + }, + "permissions": { + "none": "Nepotrebuje žiadne zvláštne oprávnenia.", + "high_risk": "Vyžaduje opatrnosť", + "email_read": { + "title": "Čítať vašu poštu", + "body": "Vidí odosielateľov, príjemcov, predmety a celý text správ, ktoré otvoríte, aj zoznamu správ." + }, + "email_write": { + "title": "Meniť štítky a príznaky pošty", + "body": "Pridáva alebo odstraňuje štítky, príznaky a ďalšie kľúčové slová pri vašich správach." + }, + "email_send": { + "title": "Meniť poštu pred odoslaním", + "body": "Číta a mení príjemcov, predmet, text a prílohy správ, ktoré píšete, a môže zastaviť odoslanie správy." + }, + "crypto_full": { + "title": "Úplný prístup k tejto aplikácii", + "body": "Beží s rovnakými právami ako samotný webmail. Môže čítať všetku vašu poštu a uložené kľúče a konať vo vašom mene. Povoľte to len doplnkom, ktorým úplne dôverujete." + }, + "email_raw_send": { + "title": "Odosielať správy, ktoré sám vytvoril", + "body": "Odosiela z vášho účtu kompletné správy, napríklad podpísané alebo šifrované." + }, + "email_blob_read": { + "title": "Čítať nespracovaný obsah správ", + "body": "Číta pôvodné údaje vašich správ a príloh aj každého súboru, ktorý priložíte pred odoslaním." + }, + "email_blob_write": { + "title": "Nahrádzať prílohy pred odoslaním", + "body": "Nahráva súbory na váš poštový server a pred odoslaním správy nahradí priložený súbor iným." + }, + "email_render_takeover": { + "title": "Meniť zobrazenie správ", + "body": "Nahrádza text správ, ktoré otvoríte, aj zoznamu správ, napríklad aby zobrazil dešifrovaný obsah." + }, + "calendar_read": { + "title": "Čítať váš kalendár", + "body": "Vidí vaše kalendáre a ich udalosti." + }, + "calendar_write": { + "title": "Meniť váš kalendár", + "body": "Vytvára, mení a odstraňuje udalosti." + }, + "contacts_read": { + "title": "Čítať vaše kontakty", + "body": "Prehľadáva a číta vaše adresáre." + }, + "contacts_write": { + "title": "Meniť vaše kontakty", + "body": "Vytvára, mení a odstraňuje kontakty a adresáre." + }, + "files_read": { + "title": "Čítať vaše súbory", + "body": "Prechádza a otvára súbory uložené vo vašom účte." + }, + "files_write": { + "title": "Meniť vaše súbory", + "body": "Nahráva, premenúva, presúva a odstraňuje súbory vo vašom účte." + }, + "identity_read": { + "title": "Vidieť vaše identity odosielateľa", + "body": "Vidí adresy, z ktorých odosielate, a ich podpisy." + }, + "identity_write": { + "title": "Meniť vaše identity odosielateľa", + "body": "Vytvára, mení a odstraňuje identity odosielateľa a podpisy." + }, + "filters_read": { + "title": "Čítať vaše filtre pošty", + "body": "Vidí pravidlá, ktoré triedia vašu prichádzajúcu poštu." + }, + "filters_write": { + "title": "Meniť vaše filtre pošty", + "body": "Pridáva pravidlá do filtrov na serveri. Takéto pravidlá môžu prichádzajúcu poštu presúvať, preposielať alebo odstraňovať." + }, + "tasks_read": { + "title": "Čítať vaše úlohy", + "body": "Vidí vaše zoznamy úloh a úlohy." + }, + "tasks_write": { + "title": "Meniť vaše úlohy", + "body": "Vytvára, mení, dokončuje a odstraňuje úlohy." + }, + "templates_read": { + "title": "Čítať vaše šablóny", + "body": "Vidí vaše uložené šablóny správ." + }, + "templates_write": { + "title": "Meniť vaše šablóny", + "body": "Vytvára, mení a odstraňuje šablóny správ." + }, + "smime_read": { + "title": "Vidieť stav S/MIME", + "body": "Vidí, či máte kľúče a certifikáty S/MIME, bez prístupu ku kľúčom." + }, + "vacation_read": { + "title": "Vidieť automatickú odpoveď v neprítomnosti", + "body": "Vidí, či je automatická odpoveď zapnutá a čo obsahuje." + }, + "vacation_write": { + "title": "Meniť automatickú odpoveď v neprítomnosti", + "body": "Zapína a vypína automatickú odpoveď a mení jej text a dátumy." + }, + "settings_read": { + "title": "Čítať vaše nastavenia", + "body": "Vidí vaše predvoľby a definície štítkov." + }, + "settings_write": { + "title": "Meniť vaše nastavenia", + "body": "Pridáva štítky, mení ich poradie a mení vaše predvoľby." + }, + "security_read": { + "title": "Vidieť zabezpečenie účtu", + "body": "Vidí, či je zapnuté dvojfaktorové overenie a šifrovanie, bez prístupu k tajným údajom." + }, + "auth_observe": { + "title": "Vedieť, kedy sa prihlásite", + "body": "Dostáva správu, keď sa prihlásite, prepnete účet alebo sa obnoví vaša relácia." + }, + "auth_emit": { + "title": "Odhlásiť vás", + "body": "Môže vás odhlásiť z webmailu." + }, + "account_read": { + "title": "Vidieť vaše účty", + "body": "Vidí účty, ktoré ste pridali, vrátane ich adries a serverov." + }, + "http_post": { + "title": "Používať server webmailu vo vašom mene", + "body": "Volá tieto koncové body servera s vaším prihlásením:" + }, + "http_fetch": { + "title": "Pripájať sa k iným webom", + "body": "Odosiela požiadavky na tieto weby. Čokoľvek, čo doplnok môže čítať, tam môže byť odoslané:" + }, + "admin_config": { + "title": "Čítať nastavenia od správcu", + "body": "Číta nastavenia, ktoré pre tento doplnok uložil váš správca." + }, + "ui_observe": { + "title": "Sledovať, kde sa v aplikácii nachádzate", + "body": "Vie, ktorá stránka je otvorená, a reaguje na odkazy, na ktoré kliknete." + }, + "ui_toolbar": { + "title": "Pridávať tlačidlá na panel nástrojov správy", + "body": "Zobrazuje vlastné tlačidlá nad otvorenou správou." + }, + "ui_app_top_banner": { + "title": "Zobrazovať banner hore", + "body": "Zobrazuje oznámenie cez hornú časť aplikácie." + }, + "ui_email_banner": { + "title": "Zobrazovať oznámenia pri správach", + "body": "Zobrazuje oznámenie nad textom otvorenej správy." + }, + "ui_email_footer": { + "title": "Pridávať obsah pod správy", + "body": "Zobrazuje vlastný obsah pod otvorenou správou." + }, + "ui_contact_cryptokeys": { + "title": "Zobrazovať šifrovacie kľúče kontaktov", + "body": "Zobrazuje a spravuje šifrovacie kľúče pri kontakte." + }, + "ui_email_details": { + "title": "Pridávať panely do podrobností správy", + "body": "Zobrazuje vlastný panel vedľa otvorenej správy alebo pod ňou." + }, + "ui_download_file": { + "title": "Ukladať súbory do zariadenia", + "body": "Ponúka na stiahnutie súbory, ktoré vytvoril." + }, + "ui_message_list_tabs": { + "title": "Pridávať karty do Doručených", + "body": "Rozdeľuje Doručené do kariet, napríklad Aktualizácie alebo Akcie." + }, + "ui_composer_toolbar": { + "title": "Pridávať tlačidlá do editora správy", + "body": "Zobrazuje vlastné tlačidlá, keď píšete správu." + }, + "ui_composer_sidebar": { + "title": "Pridávať panel do editora správy", + "body": "Zobrazuje vlastný panel vedľa správy, ktorú píšete." + }, + "ui_sidebar_widget": { + "title": "Pridávať pole do postranného panela", + "body": "Zobrazuje vlastný obsah v postrannom paneli pošty." + }, + "ui_settings_section": { + "title": "Pridávať stránku nastavení", + "body": "Zobrazuje vlastnú stránku v Nastaveniach." + }, + "ui_context_menu": { + "title": "Pridávať položky do ponuky správy", + "body": "Pridáva položky do ponuky, ktorá sa otvorí po kliknutí pravým tlačidlom na správu." + }, + "ui_navigation_rail": { + "title": "Pridávať tlačidlo na navigačnú lištu", + "body": "Zobrazuje vlastné tlačidlo na navigačnej lište." + }, + "ui_keyboard": { + "title": "Pridávať klávesové skratky", + "body": "Reaguje na klávesové skratky, ktoré sám definuje." + }, + "ui_calendar_action": { + "title": "Pridávať tlačidlá k udalostiam", + "body": "Zobrazuje vlastné tlačidlá, keď vytvárate alebo upravujete udalosť." + }, + "ui_admin_page": { + "title": "Pridávať stránku administrácie", + "body": "Zobrazuje vlastnú stránku v nastaveniach administrácie." + }, + "app_lifecycle": { + "title": "Vedieť, kedy sa aplikácia spúšťa a ukončuje", + "body": "Dostáva správu, keď sa aplikácia spustí, prejde do režimu offline alebo keď sa odhlásite." + } } }, "global_search": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 15cdec57a..f58996755 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Yetkilendirme başarısız oldu: {error}", "missing_params": "Yetkilendirme kodu veya durum eksik.", "delivery_failed": "Yetkilendirme sonucu uygulama sekmesine iletilemedi. Lütfen bu pencereyi kapatıp tekrar deneyin." + }, + "settings": { + "title": "Eklentiler", + "description": "Yöneticinizin eklediği eklentiler. Hesabınız için açıp kapatabilirsiniz.", + "description_uploads": "Eklentiler webmail'e yeni özellikler katar. Hesabınız için açıp kapatabilir veya kendi eklentilerinizi yükleyebilirsiniz.", + "description_demo": "Eklentiler demoda kullanılamaz.", + "install_button": "Eklenti yükle", + "install_hint": "Bir eklenti ZIP dosyası seçin.", + "install_hint_approval": "Bir eklenti ZIP dosyası seçin. Açabilmeniz için önce bir yöneticinin onaylaması gerekir.", + "install_uploads_off": "Yöneticiniz eklenti yüklemeye izin vermiyor.", + "install_managed_id": "Yöneticiniz bu adda bir eklentiyi zaten sağlıyor, bu yüzden değiştirilemez.", + "install_invalid": "Bu dosya yüklenebilir bir eklenti değil: {details}", + "toast_installed": "Eklenti yüklendi. Aşağıdan açabilirsiniz.", + "toast_installed_pending": "Eklenti yüklendi ve onay için yöneticinize gönderildi.", + "toast_enabled": "{name} eklentisi açıldı", + "toast_disabled": "{name} eklentisi kapatıldı", + "toast_removed": "{name} eklentisi kaldırıldı", + "toast_forced": "Yöneticiniz {name} eklentisini açık tutuyor", + "toast_approval_requested": "{name} eklentisi yöneticinizin onayını bekliyor", + "empty_title": "Henüz eklenti yok", + "empty_admin": "Yöneticiniz henüz eklenti eklemedi.", + "empty_uploads": "Başlamak için bir ZIP dosyasından eklenti yükleyin.", + "empty_demo": "Eklentiler gerçek bir hesap gerektirir, bu yüzden demoda eklenti yok.", + "syncing": "Eklentiler sunucuyla denetleniyor…", + "status_installed": "Kapalı", + "status_enabled": "Başlatılıyor", + "status_running": "Açık", + "status_disabled": "Kapalı", + "status_error": "Sorun var", + "badge_forced": "Zorunlu", + "badge_managed": "Yöneticinizden", + "badge_yours": "Sizin yüklediğiniz", + "badge_awaiting": "Onay bekliyor", + "toggle_label": "{name} eklentisini aç veya kapat", + "forced_note": "Yöneticiniz bu eklentiyi zorunlu tuttuğu için kapatılamaz.", + "permissions": "Neler yapabilir", + "permissions_managed": "Neler yapabilir (yöneticiniz onayladı)", + "settings": "Ayarlar", + "remove": "Kaldır", + "remove_title": "{name} eklentisi kaldırılsın mı?", + "remove_message": "Eklenti ve bu tarayıcıda kaydettiği her şey silinir. Daha sonra yeniden yükleyebilirsiniz.", + "remove_confirm": "Kaldır", + "error_user_plugins_off": "Yöneticiniz kullanıcıların yüklediği eklentileri kapattı, bu yüzden bu eklenti çalışmıyor.", + "error_approval_unavailable": "Bu eklenti, onay zorunlu olmadan önce yüklendi. Onaya göndermek için kaldırıp yeniden yükleyin.", + "error_approval_denied": "Yöneticiniz bu eklentiyi onaylamadı.", + "error_approval_pending": "Bu eklentinin yöneticiniz tarafından onaylanması bekleniyor." + }, + "consent": { + "title": "{name} eklentisine bu izin verilsin mi?", + "intro": "Eklenti aşağıdaki izinleri istiyor. Bu izinleri yalnızca siz izin verirseniz alır.", + "high_risk_note": "İşaretli öğeler eklentiye e-postalarınızın içeriğine erişim verir veya verileri başka yerlere göndermesine izin verir. Bunlara yalnızca güvendiğiniz eklentiler için izin verin.", + "revoke_hint": "Eklentiyi istediğiniz zaman Ayarlar'dan kapatabilir veya kaldırabilirsiniz.", + "deny": "İzin verme", + "allow": "İzin ver" + }, + "permissions": { + "none": "Özel bir izne ihtiyacı yok.", + "high_risk": "Dikkat gerektirir", + "email_read": { + "title": "E-postalarınızı okuma", + "body": "Açtığınız iletilerin ve ileti listesindekilerin gönderenlerini, alıcılarını, konularını ve tam metnini görme." + }, + "email_write": { + "title": "E-postalarınızdaki etiketleri ve bayrakları değiştirme", + "body": "İletilerinize etiket, bayrak ve diğer anahtar sözcükleri ekleme veya kaldırma." + }, + "email_send": { + "title": "Gönderilmeden önce iletileri değiştirme", + "body": "Yazdığınız iletilerin alıcılarını, konusunu, metnini ve eklerini okuma ve değiştirme, bir iletinin gönderilmesini durdurma." + }, + "crypto_full": { + "title": "Bu uygulamaya tam erişim", + "body": "Webmail'in kendisiyle aynı haklarla çalışır. Tüm e-postalarınızı ve kayıtlı anahtarlarınızı okuyabilir, sizin adınıza işlem yapabilir. Buna yalnızca tamamen güvendiğiniz eklentiler için izin verin." + }, + "email_raw_send": { + "title": "Kendi oluşturduğu iletileri gönderme", + "body": "Hesabınızdan imzalı veya şifreli iletiler gibi eksiksiz iletiler gönderme." + }, + "email_blob_read": { + "title": "İletilerin ham içeriğini okuma", + "body": "İletilerinizin ve eklerinizin özgün verilerini ve eklediğiniz her dosyayı gönderilmeden önce okuma." + }, + "email_blob_write": { + "title": "Göndermeden önce ekleri değiştirme", + "body": "Posta sunucunuza dosya yükleme ve ileti gönderilmeden önce eklediğiniz bir dosyayı başkasıyla değiştirme." + }, + "email_render_takeover": { + "title": "İletilerin gösterilme biçimini değiştirme", + "body": "Açtığınız iletilerin ve ileti listesindekilerin metnini değiştirme, örneğin şifresi çözülmüş içeriği göstermek için." + }, + "calendar_read": { + "title": "Takviminizi okuma", + "body": "Takvimlerinizi ve etkinliklerini görme." + }, + "calendar_write": { + "title": "Takviminizi değiştirme", + "body": "Etkinlik oluşturma, değiştirme ve silme." + }, + "contacts_read": { + "title": "Kişilerinizi okuma", + "body": "Adres defterlerinizde arama yapma ve onları okuma." + }, + "contacts_write": { + "title": "Kişilerinizi değiştirme", + "body": "Kişi ve adres defteri oluşturma, değiştirme ve silme." + }, + "files_read": { + "title": "Dosyalarınızı okuma", + "body": "Hesabınızda saklanan dosyalara göz atma ve onları açma." + }, + "files_write": { + "title": "Dosyalarınızı değiştirme", + "body": "Hesabınızdaki dosyaları yükleme, yeniden adlandırma, taşıma ve silme." + }, + "identity_read": { + "title": "Gönderen adreslerinizi görme", + "body": "Gönderim yaptığınız adresleri ve imzalarını görme." + }, + "identity_write": { + "title": "Gönderen adreslerinizi değiştirme", + "body": "Gönderen adresi ve imza oluşturma, değiştirme ve silme." + }, + "filters_read": { + "title": "Posta filtrelerinizi okuma", + "body": "Gelen postalarınızı sıralayan kuralları görme." + }, + "filters_write": { + "title": "Posta filtrelerinizi değiştirme", + "body": "Sunucudaki filtrelerinize kural ekleme. Bu kurallar gelen postaları taşıyabilir, iletebilir veya silebilir." + }, + "tasks_read": { + "title": "Görevlerinizi okuma", + "body": "Görev listelerinizi ve görevlerinizi görme." + }, + "tasks_write": { + "title": "Görevlerinizi değiştirme", + "body": "Görev oluşturma, değiştirme, tamamlama ve silme." + }, + "templates_read": { + "title": "Şablonlarınızı okuma", + "body": "Kayıtlı ileti şablonlarınızı görme." + }, + "templates_write": { + "title": "Şablonlarınızı değiştirme", + "body": "İleti şablonu oluşturma, değiştirme ve silme." + }, + "smime_read": { + "title": "S/MIME durumunuzu görme", + "body": "Anahtarlara erişmeden S/MIME anahtarlarınızın ve sertifikalarınızın olup olmadığını görme." + }, + "vacation_read": { + "title": "Ofis dışı yanıtınızı görme", + "body": "Otomatik yanıtınızın açık olup olmadığını ve ne yazdığını görme." + }, + "vacation_write": { + "title": "Ofis dışı yanıtınızı değiştirme", + "body": "Otomatik yanıtınızı açıp kapatma, metnini ve tarihlerini değiştirme." + }, + "settings_read": { + "title": "Ayarlarınızı okuma", + "body": "Tercihlerinizi ve etiket tanımlarınızı görme." + }, + "settings_write": { + "title": "Ayarlarınızı değiştirme", + "body": "Etiket ekleme, sıralarını değiştirme ve tercihlerinizi değiştirme." + }, + "security_read": { + "title": "Hesap güvenliğinizi görme", + "body": "Gizli bilgilere erişmeden iki faktörlü kimlik doğrulamanın ve şifrelemenin açık olup olmadığını görme." + }, + "auth_observe": { + "title": "Giriş yaptığınızı bilme", + "body": "Giriş yaptığınızda, hesap değiştirdiğinizde veya oturumunuz yenilendiğinde haber alma." + }, + "auth_emit": { + "title": "Oturumunuzu kapatma", + "body": "Webmail'den çıkış yapmanızı sağlama." + }, + "account_read": { + "title": "Hesaplarınızı görme", + "body": "Eklediğiniz hesapları adresleri ve sunucularıyla birlikte görme." + }, + "http_post": { + "title": "Sizin adınıza webmail sunucusunu kullanma", + "body": "Şu sunucu uç noktalarını oturum bilgilerinizle çağırma:" + }, + "http_fetch": { + "title": "Başka web sitelerine bağlanma", + "body": "Şu sitelere istek gönderme. Eklentinin okuyabildiği her şey oraya gönderilebilir:" + }, + "admin_config": { + "title": "Yöneticinizin ayarlarını okuma", + "body": "Yöneticinizin bu eklenti için kaydettiği ayarları okuma." + }, + "ui_observe": { + "title": "Uygulamada nerede olduğunuzu izleme", + "body": "Hangi sayfanın açık olduğunu bilme ve tıkladığınız bağlantılara tepki verme." + }, + "ui_toolbar": { + "title": "İleti araç çubuğuna düğme ekleme", + "body": "Açık bir iletinin üstünde kendi düğmelerini gösterme." + }, + "ui_app_top_banner": { + "title": "Üstte bir başlık bandı gösterme", + "body": "Uygulamanın üst kısmında bir bildirim gösterme." + }, + "ui_email_banner": { + "title": "İletilerde bildirim gösterme", + "body": "Açık bir iletinin metninin üstünde bildirim gösterme." + }, + "ui_email_footer": { + "title": "İletilerin altına içerik ekleme", + "body": "Açık bir iletinin altında kendi içeriğini gösterme." + }, + "ui_contact_cryptokeys": { + "title": "Kişilerin şifreleme anahtarlarını gösterme", + "body": "Bir kişinin şifreleme anahtarlarını gösterme ve yönetme." + }, + "ui_email_details": { + "title": "İleti ayrıntılarına panel ekleme", + "body": "Açık bir iletinin yanında veya altında kendi panelini gösterme." + }, + "ui_download_file": { + "title": "Dosyaları cihazınıza kaydetme", + "body": "Oluşturduğu dosyaları indirmeniz için sunma." + }, + "ui_message_list_tabs": { + "title": "Gelen Kutusu'na sekme ekleme", + "body": "Gelen kutunuzu Güncellemeler veya Promosyonlar gibi sekmelere ayırma." + }, + "ui_composer_toolbar": { + "title": "İleti düzenleyiciye düğme ekleme", + "body": "Siz ileti yazarken kendi düğmelerini gösterme." + }, + "ui_composer_sidebar": { + "title": "İleti düzenleyiciye panel ekleme", + "body": "Yazdığınız iletinin yanında kendi panelini gösterme." + }, + "ui_sidebar_widget": { + "title": "Kenar çubuğuna kutu ekleme", + "body": "Posta kenar çubuğunda kendi içeriğini gösterme." + }, + "ui_settings_section": { + "title": "Ayarlar sayfası ekleme", + "body": "Ayarlar'da kendi sayfasını gösterme." + }, + "ui_context_menu": { + "title": "İleti menüsüne öğe ekleme", + "body": "Bir iletiye sağ tıkladığınızda açılan menüye öğe ekleme." + }, + "ui_navigation_rail": { + "title": "Gezinme çubuğuna öğe ekleme", + "body": "Gezinme çubuğunda kendi düğmesini gösterme." + }, + "ui_keyboard": { + "title": "Klavye kısayolları ekleme", + "body": "Tanımladığı klavye kısayollarına tepki verme." + }, + "ui_calendar_action": { + "title": "Etkinliklere düğme ekleme", + "body": "Bir etkinlik oluştururken veya düzenlerken kendi düğmelerini gösterme." + }, + "ui_admin_page": { + "title": "Yönetim sayfası ekleme", + "body": "Yönetim ayarlarında kendi sayfasını gösterme." + }, + "app_lifecycle": { + "title": "Uygulamanın başladığını ve durduğunu bilme", + "body": "Uygulama başladığında, çevrimdışı olduğunda veya çıkış yaptığınızda haber alma." + } } }, "global_search": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 2838cae53..f6d50a2e7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "Не вдалося виконати авторизацію: {error}", "missing_params": "Відсутній код авторизації або стан.", "delivery_failed": "Не вдалося передати результат авторизації на вкладку програми. Закрийте це вікно та спробуйте ще раз." + }, + "settings": { + "title": "Плагіни", + "description": "Плагіни, які додав ваш адміністратор. Вмикайте й вимикайте їх для свого облікового запису.", + "description_uploads": "Плагіни додають у webmail нові функції. Вмикайте й вимикайте їх для свого облікового запису або встановлюйте власні.", + "description_demo": "У демо-режимі плагіни недоступні.", + "install_button": "Встановити плагін", + "install_hint": "Виберіть ZIP-файл плагіна.", + "install_hint_approval": "Виберіть ZIP-файл плагіна. Перш ніж ви зможете його ввімкнути, його має схвалити адміністратор.", + "install_uploads_off": "Ваш адміністратор не дозволяє встановлювати плагіни.", + "install_managed_id": "Ваш адміністратор уже надає плагін із такою назвою, тому його не можна замінити.", + "install_invalid": "Цей файл не є плагіном, який можна встановити: {details}", + "toast_installed": "Плагін встановлено. Увімкніть його нижче.", + "toast_installed_pending": "Плагін встановлено й надіслано вашому адміністратору на схвалення.", + "toast_enabled": "Плагін {name} увімкнено", + "toast_disabled": "Плагін {name} вимкнено", + "toast_removed": "Плагін {name} видалено", + "toast_forced": "Ваш адміністратор вимагає, щоб плагін {name} залишався ввімкненим", + "toast_approval_requested": "Плагін {name} чекає на схвалення вашим адміністратором", + "empty_title": "Плагінів поки немає", + "empty_admin": "Ваш адміністратор не додав жодного плагіна.", + "empty_uploads": "Щоб почати, встановіть плагін із ZIP-файлу.", + "empty_demo": "Плагінам потрібен справжній обліковий запис, тому в демо-режимі їх немає.", + "syncing": "Перевірка плагінів на сервері…", + "status_installed": "Вимкнено", + "status_enabled": "Запускається", + "status_running": "Увімкнено", + "status_disabled": "Вимкнено", + "status_error": "Помилка", + "badge_forced": "Обов'язковий", + "badge_managed": "Від вашого адміністратора", + "badge_yours": "Встановлено вами", + "badge_awaiting": "Чекає на схвалення", + "toggle_label": "Увімкнути або вимкнути плагін {name}", + "forced_note": "Ваш адміністратор вимагає цей плагін, тому його не можна вимкнути.", + "permissions": "Що він може робити", + "permissions_managed": "Що він може робити (схвалено вашим адміністратором)", + "settings": "Налаштування", + "remove": "Видалити", + "remove_title": "Видалити плагін {name}?", + "remove_message": "Плагін і все, що він зберіг у цьому браузері, буде видалено. Пізніше його можна встановити знову.", + "remove_confirm": "Видалити", + "error_user_plugins_off": "Ваш адміністратор вимкнув плагіни, встановлені користувачами, тому цей плагін не працює.", + "error_approval_unavailable": "Цей плагін встановлено ще до того, як з'явилося схвалення. Видаліть і встановіть його знову, щоб надіслати на схвалення.", + "error_approval_denied": "Ваш адміністратор не схвалив цей плагін.", + "error_approval_pending": "Очікується, що ваш адміністратор схвалить цей плагін." + }, + "consent": { + "title": "Дозволити плагіну {name} це?", + "intro": "Плагін запитує дозволи, наведені нижче. Він отримає їх, лише якщо ви дозволите.", + "high_risk_note": "Позначені пункти дають плагіну доступ до вмісту вашої пошти або дозволяють йому передавати дані деінде. Дозволяйте їх лише плагінам, яким довіряєте.", + "revoke_hint": "Ви будь-коли можете вимкнути або видалити плагін у налаштуваннях.", + "deny": "Не дозволяти", + "allow": "Дозволити" + }, + "permissions": { + "none": "Йому не потрібні особливі дозволи.", + "high_risk": "Потребує обережності", + "email_read": { + "title": "Читати вашу пошту", + "body": "Бачити відправників, одержувачів, теми й повний текст відкритих листів і листів у списку повідомлень." + }, + "email_write": { + "title": "Змінювати теги й позначки листів", + "body": "Додавати й прибирати теги, позначки та інші ключові слова у ваших листах." + }, + "email_send": { + "title": "Змінювати листи перед надсиланням", + "body": "Читати й змінювати одержувачів, тему, текст і вкладення листів, які ви пишете, а також зупиняти надсилання листа." + }, + "crypto_full": { + "title": "Повний доступ до програми", + "body": "Працює з тими самими правами, що й сам webmail. Може читати всю вашу пошту та збережені ключі й діяти від вашого імені. Дозволяйте це лише плагінам, яким повністю довіряєте." + }, + "email_raw_send": { + "title": "Надсилати власні листи", + "body": "Надсилати з вашого облікового запису готові листи, наприклад підписані або зашифровані." + }, + "email_blob_read": { + "title": "Читати вихідні дані листів", + "body": "Читати вихідні дані ваших листів і вкладень, а також кожного файлу, який ви прикріплюєте, до надсилання." + }, + "email_blob_write": { + "title": "Замінювати вкладення перед надсиланням", + "body": "Завантажувати файли на ваш поштовий сервер і замінювати прикріплений файл іншим до надсилання листа." + }, + "email_render_takeover": { + "title": "Змінювати показ листів", + "body": "Замінювати текст відкритих листів і листів у списку повідомлень, наприклад щоб показати розшифрований вміст." + }, + "calendar_read": { + "title": "Читати ваш календар", + "body": "Бачити ваші календарі та їхні події." + }, + "calendar_write": { + "title": "Змінювати ваш календар", + "body": "Створювати, змінювати й видаляти події." + }, + "contacts_read": { + "title": "Читати ваші контакти", + "body": "Шукати у ваших адресних книгах і читати їх." + }, + "contacts_write": { + "title": "Змінювати ваші контакти", + "body": "Створювати, змінювати й видаляти контакти та адресні книги." + }, + "files_read": { + "title": "Читати ваші файли", + "body": "Переглядати й відкривати файли, що зберігаються у вашому обліковому записі." + }, + "files_write": { + "title": "Змінювати ваші файли", + "body": "Завантажувати, перейменовувати, переміщувати й видаляти файли у вашому обліковому записі." + }, + "identity_read": { + "title": "Бачити ваші адреси відправника", + "body": "Бачити адреси, з яких ви надсилаєте листи, та їхні підписи." + }, + "identity_write": { + "title": "Змінювати ваші адреси відправника", + "body": "Створювати, змінювати й видаляти адреси відправника та підписи." + }, + "filters_read": { + "title": "Читати ваші фільтри пошти", + "body": "Бачити правила, які сортують вхідну пошту." + }, + "filters_write": { + "title": "Змінювати ваші фільтри пошти", + "body": "Додавати правила до фільтрів на сервері. Такі правила можуть переміщувати, пересилати або видаляти вхідну пошту." + }, + "tasks_read": { + "title": "Читати ваші завдання", + "body": "Бачити ваші списки завдань і завдання." + }, + "tasks_write": { + "title": "Змінювати ваші завдання", + "body": "Створювати, змінювати, завершувати й видаляти завдання." + }, + "templates_read": { + "title": "Читати ваші шаблони", + "body": "Бачити збережені шаблони листів." + }, + "templates_write": { + "title": "Змінювати ваші шаблони", + "body": "Створювати, змінювати й видаляти шаблони листів." + }, + "smime_read": { + "title": "Бачити ваш стан S/MIME", + "body": "Бачити, чи маєте ви ключі та сертифікати S/MIME, без доступу до самих ключів." + }, + "vacation_read": { + "title": "Бачити вашу автовідповідь", + "body": "Бачити, чи ввімкнено автовідповідь і що в ній написано." + }, + "vacation_write": { + "title": "Змінювати вашу автовідповідь", + "body": "Вмикати й вимикати автовідповідь, змінювати її текст і дати." + }, + "settings_read": { + "title": "Читати ваші налаштування", + "body": "Бачити ваші параметри та визначення тегів." + }, + "settings_write": { + "title": "Змінювати ваші налаштування", + "body": "Додавати теги, змінювати їхній порядок і змінювати ваші параметри." + }, + "security_read": { + "title": "Бачити безпеку облікового запису", + "body": "Бачити, чи ввімкнено двофакторну автентифікацію та шифрування, без доступу до секретів." + }, + "auth_observe": { + "title": "Знати, коли ви входите", + "body": "Отримувати сповіщення, коли ви входите, перемикаєте обліковий запис або ваш сеанс подовжується." + }, + "auth_emit": { + "title": "Виконувати вихід", + "body": "Виконувати ваш вихід із webmail." + }, + "account_read": { + "title": "Бачити ваші облікові записи", + "body": "Бачити додані вами облікові записи, їхні адреси та сервери." + }, + "http_post": { + "title": "Звертатися до сервера webmail від вашого імені", + "body": "Викликати ці адреси сервера з вашими обліковими даними:" + }, + "http_fetch": { + "title": "Підключатися до інших сайтів", + "body": "Надсилати запити на ці сайти. Туди може потрапити все, що плагін може прочитати:" + }, + "admin_config": { + "title": "Читати свої налаштування від адміністратора", + "body": "Читати налаштування, які ваш адміністратор зберіг для цього плагіна." + }, + "ui_observe": { + "title": "Стежити, де ви в програмі", + "body": "Знати, яку сторінку відкрито, і реагувати на посилання, які ви натискаєте." + }, + "ui_toolbar": { + "title": "Додавати кнопки на панель інструментів листа", + "body": "Показувати власні кнопки над відкритим листом." + }, + "ui_app_top_banner": { + "title": "Показувати банер угорі", + "body": "Показувати сповіщення у верхній частині програми." + }, + "ui_email_banner": { + "title": "Показувати сповіщення в листах", + "body": "Показувати сповіщення над текстом відкритого листа." + }, + "ui_email_footer": { + "title": "Додавати вміст під листами", + "body": "Показувати власний вміст під відкритим листом." + }, + "ui_contact_cryptokeys": { + "title": "Показувати ключі шифрування контактів", + "body": "Показувати ключі шифрування контакту й керувати ними." + }, + "ui_email_details": { + "title": "Додавати панелі до листа", + "body": "Показувати власну панель поруч із відкритим листом або під ним." + }, + "ui_download_file": { + "title": "Зберігати файли на ваш пристрій", + "body": "Пропонувати для завантаження створені ним файли." + }, + "ui_message_list_tabs": { + "title": "Додавати вкладки у «Вхідні»", + "body": "Розподіляти вхідні листи за вкладками, наприклад «Оновлення» або «Акції»." + }, + "ui_composer_toolbar": { + "title": "Додавати кнопки в редактор листа", + "body": "Показувати власні кнопки, поки ви пишете лист." + }, + "ui_composer_sidebar": { + "title": "Додавати панель у редактор листа", + "body": "Показувати власну панель поруч із листом, який ви пишете." + }, + "ui_sidebar_widget": { + "title": "Додавати блок на бічну панель", + "body": "Показувати власний вміст на бічній панелі пошти." + }, + "ui_settings_section": { + "title": "Додавати сторінку налаштувань", + "body": "Показувати власну сторінку в налаштуваннях." + }, + "ui_context_menu": { + "title": "Додавати пункти в меню листа", + "body": "Додавати пункти в меню, яке відкривається, коли ви клацаєте лист правою кнопкою." + }, + "ui_navigation_rail": { + "title": "Додавати кнопку на панель навігації", + "body": "Показувати власну кнопку на панелі навігації." + }, + "ui_keyboard": { + "title": "Додавати сполучення клавіш", + "body": "Реагувати на сполучення клавіш, які він визначає." + }, + "ui_calendar_action": { + "title": "Додавати кнопки до подій", + "body": "Показувати власні кнопки, коли ви створюєте або редагуєте подію." + }, + "ui_admin_page": { + "title": "Додавати сторінку адміністрування", + "body": "Показувати власну сторінку в налаштуваннях адміністратора." + }, + "app_lifecycle": { + "title": "Знати, коли програма запускається й зупиняється", + "body": "Отримувати сповіщення, коли програма запускається, втрачає зв'язок або ви виходите." + } } }, "global_search": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3138e03a2..4399d8b3a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "授權失敗:{error}", "missing_params": "缺少授權碼或狀態。", "delivery_failed": "無法將授權結果傳遞至應用程式分頁。請關閉此視窗並再試一次。" + }, + "settings": { + "title": "外掛程式", + "description": "由管理員新增的外掛程式。您可以為自己的帳號開啟或關閉。", + "description_uploads": "外掛程式可為網頁郵件新增功能。您可以為自己的帳號開啟或關閉,也可以自行安裝。", + "description_demo": "示範模式無法使用外掛程式。", + "install_button": "安裝外掛程式", + "install_hint": "請選擇外掛程式 ZIP 檔案。", + "install_hint_approval": "請選擇外掛程式 ZIP 檔案。需經管理員核准後才能開啟。", + "install_uploads_off": "管理員不允許安裝外掛程式。", + "install_managed_id": "管理員已提供同名的外掛程式,因此無法取代。", + "install_invalid": "此檔案不是可安裝的外掛程式:{details}", + "toast_installed": "外掛程式已安裝。請在下方開啟。", + "toast_installed_pending": "外掛程式已安裝,並已送交管理員核准。", + "toast_enabled": "{name} 已開啟", + "toast_disabled": "{name} 已關閉", + "toast_removed": "{name} 已移除", + "toast_forced": "管理員要求 {name} 保持開啟", + "toast_approval_requested": "{name} 正在等待管理員核准", + "empty_title": "尚無外掛程式", + "empty_admin": "管理員尚未新增任何外掛程式。", + "empty_uploads": "從 ZIP 檔案安裝外掛程式即可開始使用。", + "empty_demo": "外掛程式需要真實帳號,因此示範模式中沒有外掛程式。", + "syncing": "正在與伺服器核對外掛程式…", + "status_installed": "已關閉", + "status_enabled": "啟動中", + "status_running": "已開啟", + "status_disabled": "已關閉", + "status_error": "發生問題", + "badge_forced": "必要", + "badge_managed": "由管理員提供", + "badge_yours": "由您安裝", + "badge_awaiting": "等待核准", + "toggle_label": "開啟或關閉 {name}", + "forced_note": "管理員要求使用此外掛程式,因此無法關閉。", + "permissions": "可執行的動作", + "permissions_managed": "可執行的動作(已由管理員核准)", + "settings": "設定", + "remove": "移除", + "remove_title": "要移除 {name} 嗎?", + "remove_message": "外掛程式及其儲存在此瀏覽器中的所有內容都會被刪除。您之後可以再次安裝。", + "remove_confirm": "移除", + "error_user_plugins_off": "管理員已關閉使用者安裝的外掛程式,因此此外掛程式不會執行。", + "error_approval_unavailable": "此外掛程式是在需要核准之前安裝的。請移除後重新安裝,以送交核准。", + "error_approval_denied": "管理員未核准此外掛程式。", + "error_approval_pending": "正在等待管理員核准此外掛程式。" + }, + "consent": { + "title": "要允許 {name} 執行這些動作嗎?", + "intro": "此外掛程式要求下列權限。只有在您允許後才會取得。", + "high_risk_note": "標示的項目會讓外掛程式存取您的郵件內容,或將資料傳送到其他地方。請只對您信任的外掛程式允許。", + "revoke_hint": "您隨時可以在設定中關閉或移除此外掛程式。", + "deny": "不允許", + "allow": "允許" + }, + "permissions": { + "none": "不需要特殊權限。", + "high_risk": "需留意", + "email_read": { + "title": "讀取您的郵件", + "body": "查看您開啟的郵件和郵件清單中的寄件者、收件者、主旨和完整內文。" + }, + "email_write": { + "title": "變更郵件的標籤和旗標", + "body": "為您的郵件新增或移除標籤、旗標和其他關鍵字。" + }, + "email_send": { + "title": "在傳送前變更郵件", + "body": "讀取並變更您撰寫的郵件的收件者、主旨、內文和附件,並可阻止郵件傳送。" + }, + "crypto_full": { + "title": "完整存取此應用程式", + "body": "以與網頁郵件本身相同的權限執行。它可以讀取您所有的郵件和已儲存的金鑰,並代表您執行動作。請只對您完全信任的外掛程式允許。" + }, + "email_raw_send": { + "title": "傳送它自行建立的郵件", + "body": "從您的帳號傳送完整的郵件,例如已簽署或已加密的郵件。" + }, + "email_blob_read": { + "title": "讀取郵件的原始內容", + "body": "讀取您的郵件和附件的原始資料,以及傳送前您附加的每個檔案。" + }, + "email_blob_write": { + "title": "在傳送前取代附件", + "body": "將檔案上傳到您的郵件伺服器,並在郵件傳送前以另一個檔案取代您附加的檔案。" + }, + "email_render_takeover": { + "title": "變更郵件的顯示方式", + "body": "取代您開啟的郵件和郵件清單中的內文,例如用來顯示解密後的內容。" + }, + "calendar_read": { + "title": "讀取您的行事曆", + "body": "查看您的行事曆及其中的活動。" + }, + "calendar_write": { + "title": "變更您的行事曆", + "body": "建立、變更和刪除活動。" + }, + "contacts_read": { + "title": "讀取您的聯絡人", + "body": "搜尋和讀取您的通訊錄。" + }, + "contacts_write": { + "title": "變更您的聯絡人", + "body": "建立、變更和刪除聯絡人及通訊錄。" + }, + "files_read": { + "title": "讀取您的檔案", + "body": "瀏覽和開啟您帳號中儲存的檔案。" + }, + "files_write": { + "title": "變更您的檔案", + "body": "上傳、重新命名、移動和刪除您帳號中的檔案。" + }, + "identity_read": { + "title": "查看您的寄件身分", + "body": "查看您用來傳送郵件的地址及其簽名檔。" + }, + "identity_write": { + "title": "變更您的寄件身分", + "body": "建立、變更和刪除寄件身分及簽名檔。" + }, + "filters_read": { + "title": "讀取您的郵件篩選規則", + "body": "查看用來整理收到郵件的規則。" + }, + "filters_write": { + "title": "變更您的郵件篩選規則", + "body": "在伺服器端篩選規則中新增規則。這些規則可以移動、轉寄或刪除收到的郵件。" + }, + "tasks_read": { + "title": "讀取您的任務", + "body": "查看您的任務清單和任務。" + }, + "tasks_write": { + "title": "變更您的任務", + "body": "建立、變更、完成和刪除任務。" + }, + "templates_read": { + "title": "讀取您的範本", + "body": "查看您儲存的郵件範本。" + }, + "templates_write": { + "title": "變更您的範本", + "body": "建立、變更和刪除郵件範本。" + }, + "smime_read": { + "title": "查看您的 S/MIME 狀態", + "body": "查看您是否有 S/MIME 金鑰和憑證,但無法存取金鑰。" + }, + "vacation_read": { + "title": "查看您的外出自動回覆", + "body": "查看自動回覆是否已開啟及其內容。" + }, + "vacation_write": { + "title": "變更您的外出自動回覆", + "body": "開啟或關閉自動回覆,並變更其內容和日期。" + }, + "settings_read": { + "title": "讀取您的設定", + "body": "查看您的偏好設定和標籤定義。" + }, + "settings_write": { + "title": "變更您的設定", + "body": "新增標籤、調整標籤順序,並變更您的偏好設定。" + }, + "security_read": { + "title": "查看您的帳號安全性", + "body": "查看雙因素驗證和加密是否已開啟,但無法存取機密資訊。" + }, + "auth_observe": { + "title": "得知您何時登入", + "body": "在您登入、切換帳號或工作階段更新時收到通知。" + }, + "auth_emit": { + "title": "將您登出", + "body": "將您從網頁郵件登出。" + }, + "account_read": { + "title": "查看您的帳號", + "body": "查看您新增的帳號及其地址和伺服器。" + }, + "http_post": { + "title": "代表您使用網頁郵件伺服器", + "body": "使用您的登入資訊呼叫下列伺服器端點:" + }, + "http_fetch": { + "title": "連線到其他網站", + "body": "向下列網站傳送要求。外掛程式能讀取的任何內容都可能被傳送到這些網站:" + }, + "admin_config": { + "title": "讀取管理員為其儲存的設定", + "body": "讀取管理員為此外掛程式儲存的設定。" + }, + "ui_observe": { + "title": "追蹤您在應用程式中的位置", + "body": "得知目前開啟的頁面,並對您點選的連結做出回應。" + }, + "ui_toolbar": { + "title": "在郵件工具列新增按鈕", + "body": "在開啟的郵件上方顯示它自己的按鈕。" + }, + "ui_app_top_banner": { + "title": "在頂端顯示橫幅", + "body": "在應用程式頂端顯示一則通知。" + }, + "ui_email_banner": { + "title": "在郵件上顯示通知", + "body": "在開啟的郵件內文上方顯示一則通知。" + }, + "ui_email_footer": { + "title": "在郵件下方新增內容", + "body": "在開啟的郵件下方顯示它自己的內容。" + }, + "ui_contact_cryptokeys": { + "title": "顯示聯絡人的加密金鑰", + "body": "顯示並管理聯絡人的加密金鑰。" + }, + "ui_email_details": { + "title": "在郵件詳細資料新增面板", + "body": "在開啟的郵件旁邊或下方顯示它自己的面板。" + }, + "ui_download_file": { + "title": "將檔案儲存到您的裝置", + "body": "提供它建立的檔案供下載。" + }, + "ui_message_list_tabs": { + "title": "為收件匣新增分頁", + "body": "將收件匣分成「最新快訊」或「促銷內容」等分頁。" + }, + "ui_composer_toolbar": { + "title": "在撰寫視窗新增按鈕", + "body": "在您撰寫郵件時顯示它自己的按鈕。" + }, + "ui_composer_sidebar": { + "title": "在撰寫視窗新增面板", + "body": "在您撰寫的郵件旁邊顯示它自己的面板。" + }, + "ui_sidebar_widget": { + "title": "在側邊欄新增方塊", + "body": "在郵件側邊欄顯示它自己的內容。" + }, + "ui_settings_section": { + "title": "新增設定頁面", + "body": "在設定中顯示它自己的頁面。" + }, + "ui_context_menu": { + "title": "新增到郵件選單", + "body": "在您按右鍵點選郵件時開啟的選單中新增項目。" + }, + "ui_navigation_rail": { + "title": "新增到導覽列", + "body": "在導覽列顯示它自己的按鈕。" + }, + "ui_keyboard": { + "title": "新增鍵盤快速鍵", + "body": "回應它定義的鍵盤快速鍵。" + }, + "ui_calendar_action": { + "title": "為活動新增按鈕", + "body": "在您建立或編輯活動時顯示它自己的按鈕。" + }, + "ui_admin_page": { + "title": "新增管理頁面", + "body": "在管理設定中顯示它自己的頁面。" + }, + "app_lifecycle": { + "title": "得知應用程式何時啟動和停止", + "body": "在應用程式啟動、離線或您登出時收到通知。" + } } }, "global_search": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 4629d8574..d12bc01c5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3970,6 +3970,276 @@ "failed_detail": "授权失败:{error}", "missing_params": "缺少授权码或状态。", "delivery_failed": "无法将授权结果传递到应用标签页。请关闭此窗口并重试。" + }, + "settings": { + "title": "插件", + "description": "由管理员添加的插件。可以为您的账户开启或关闭。", + "description_uploads": "插件可为网页邮箱添加功能。可以为您的账户开启或关闭,也可以自行安装。", + "description_demo": "演示模式中无法使用插件。", + "install_button": "安装插件", + "install_hint": "请选择插件 ZIP 文件。", + "install_hint_approval": "请选择插件 ZIP 文件。需要管理员批准后才能开启。", + "install_uploads_off": "管理员不允许安装插件。", + "install_managed_id": "管理员已提供同名插件,因此无法替换。", + "install_invalid": "此文件不是可安装的插件:{details}", + "toast_installed": "插件已安装。请在下方开启。", + "toast_installed_pending": "插件已安装,并已提交给管理员审批。", + "toast_enabled": "{name} 已开启", + "toast_disabled": "{name} 已关闭", + "toast_removed": "{name} 已移除", + "toast_forced": "管理员要求 {name} 保持开启", + "toast_approval_requested": "{name} 正在等待管理员批准", + "empty_title": "暂无插件", + "empty_admin": "管理员尚未添加任何插件。", + "empty_uploads": "从 ZIP 文件安装插件即可开始使用。", + "empty_demo": "插件需要真实账户,因此演示模式中没有插件。", + "syncing": "正在与服务器核对插件…", + "status_installed": "已关闭", + "status_enabled": "正在启动", + "status_running": "已开启", + "status_disabled": "已关闭", + "status_error": "出现问题", + "badge_forced": "必需", + "badge_managed": "由管理员提供", + "badge_yours": "由您安装", + "badge_awaiting": "等待批准", + "toggle_label": "开启或关闭 {name}", + "forced_note": "管理员要求使用此插件,因此无法关闭。", + "permissions": "可执行的操作", + "permissions_managed": "可执行的操作(已由管理员批准)", + "settings": "设置", + "remove": "移除", + "remove_title": "移除 {name}?", + "remove_message": "插件及其在此浏览器中存储的所有内容都将被删除。您以后可以重新安装。", + "remove_confirm": "移除", + "error_user_plugins_off": "管理员已关闭用户安装的插件,因此此插件不会运行。", + "error_approval_unavailable": "此插件是在需要审批之前安装的。请移除后重新安装,以提交审批。", + "error_approval_denied": "管理员未批准此插件。", + "error_approval_pending": "正在等待管理员批准此插件。" + }, + "consent": { + "title": "允许 {name} 执行这些操作?", + "intro": "此插件请求以下权限。只有在您允许后才会获得。", + "high_risk_note": "带标记的项目会让插件访问您的邮件内容,或将数据发送到其他地方。请只对您信任的插件允许。", + "revoke_hint": "您可以随时在设置中关闭或移除此插件。", + "deny": "不允许", + "allow": "允许" + }, + "permissions": { + "none": "不需要特殊权限。", + "high_risk": "需谨慎", + "email_read": { + "title": "读取您的邮件", + "body": "查看您打开的邮件和邮件列表中的发件人、收件人、主题和完整正文。" + }, + "email_write": { + "title": "更改邮件的标签和旗标", + "body": "为您的邮件添加或移除标签、旗标和其他关键词。" + }, + "email_send": { + "title": "在发送前更改邮件", + "body": "读取并更改您撰写的邮件的收件人、主题、正文和附件,并可阻止邮件发送。" + }, + "crypto_full": { + "title": "完全访问此应用", + "body": "以与网页邮箱本身相同的权限运行。它可以读取您的所有邮件和已存储的密钥,并代表您执行操作。请只对您完全信任的插件允许。" + }, + "email_raw_send": { + "title": "发送它自行构建的邮件", + "body": "从您的账户发送完整的邮件,例如已签名或已加密的邮件。" + }, + "email_blob_read": { + "title": "读取邮件的原始内容", + "body": "读取您的邮件和附件的原始数据,以及发送前您附加的每个文件。" + }, + "email_blob_write": { + "title": "在发送前替换附件", + "body": "将文件上传到您的邮件服务器,并在邮件发送前用另一个文件替换您附加的文件。" + }, + "email_render_takeover": { + "title": "更改邮件的显示方式", + "body": "替换您打开的邮件和邮件列表中的正文,例如用于显示解密后的内容。" + }, + "calendar_read": { + "title": "读取您的日历", + "body": "查看您的日历及其中的活动。" + }, + "calendar_write": { + "title": "更改您的日历", + "body": "创建、更改和删除活动。" + }, + "contacts_read": { + "title": "读取您的联系人", + "body": "搜索和读取您的通讯录。" + }, + "contacts_write": { + "title": "更改您的联系人", + "body": "创建、更改和删除联系人及通讯录。" + }, + "files_read": { + "title": "读取您的文件", + "body": "浏览和打开您账户中存储的文件。" + }, + "files_write": { + "title": "更改您的文件", + "body": "上传、重命名、移动和删除您账户中的文件。" + }, + "identity_read": { + "title": "查看您的发件身份", + "body": "查看您用于发送邮件的地址及其签名。" + }, + "identity_write": { + "title": "更改您的发件身份", + "body": "创建、更改和删除发件身份及签名。" + }, + "filters_read": { + "title": "读取您的邮件过滤器", + "body": "查看用于整理收到邮件的规则。" + }, + "filters_write": { + "title": "更改您的邮件过滤器", + "body": "向服务器端过滤器添加规则。这些规则可以移动、转发或删除收到的邮件。" + }, + "tasks_read": { + "title": "读取您的任务", + "body": "查看您的任务列表和任务。" + }, + "tasks_write": { + "title": "更改您的任务", + "body": "创建、更改、完成和删除任务。" + }, + "templates_read": { + "title": "读取您的模板", + "body": "查看您保存的邮件模板。" + }, + "templates_write": { + "title": "更改您的模板", + "body": "创建、更改和删除邮件模板。" + }, + "smime_read": { + "title": "查看您的 S/MIME 状态", + "body": "查看您是否拥有 S/MIME 密钥和证书,但无法访问密钥。" + }, + "vacation_read": { + "title": "查看您的外出自动回复", + "body": "查看自动回复是否已开启及其内容。" + }, + "vacation_write": { + "title": "更改您的外出自动回复", + "body": "开启或关闭自动回复,并更改其内容和日期。" + }, + "settings_read": { + "title": "读取您的设置", + "body": "查看您的偏好设置和标签定义。" + }, + "settings_write": { + "title": "更改您的设置", + "body": "添加标签、调整标签顺序,并更改您的偏好设置。" + }, + "security_read": { + "title": "查看您的账户安全", + "body": "查看双因素身份验证和加密是否已开启,但无法访问机密信息。" + }, + "auth_observe": { + "title": "得知您何时登录", + "body": "在您登录、切换账户或会话续期时收到通知。" + }, + "auth_emit": { + "title": "让您退出登录", + "body": "让您退出网页邮箱。" + }, + "account_read": { + "title": "查看您的账户", + "body": "查看您添加的账户及其地址和服务器。" + }, + "http_post": { + "title": "代表您使用网页邮箱服务器", + "body": "使用您的登录信息调用以下服务器端点:" + }, + "http_fetch": { + "title": "连接其他网站", + "body": "向以下网站发送请求。插件能读取的任何内容都可能被发送到这些网站:" + }, + "admin_config": { + "title": "读取管理员为其保存的设置", + "body": "读取管理员为此插件保存的设置。" + }, + "ui_observe": { + "title": "跟踪您在应用中的位置", + "body": "了解当前打开的页面,并对您点击的链接做出响应。" + }, + "ui_toolbar": { + "title": "在邮件工具栏中添加按钮", + "body": "在打开的邮件上方显示它自己的按钮。" + }, + "ui_app_top_banner": { + "title": "在顶部显示横幅", + "body": "在应用顶部显示一条通知。" + }, + "ui_email_banner": { + "title": "在邮件上显示通知", + "body": "在打开的邮件正文上方显示一条通知。" + }, + "ui_email_footer": { + "title": "在邮件下方添加内容", + "body": "在打开的邮件下方显示它自己的内容。" + }, + "ui_contact_cryptokeys": { + "title": "显示联系人的加密密钥", + "body": "显示并管理联系人的加密密钥。" + }, + "ui_email_details": { + "title": "在邮件详情中添加面板", + "body": "在打开的邮件旁边或下方显示它自己的面板。" + }, + "ui_download_file": { + "title": "将文件保存到您的设备", + "body": "提供它创建的文件供下载。" + }, + "ui_message_list_tabs": { + "title": "为收件箱添加标签页", + "body": "将收件箱分为“动态”或“推广”等标签页。" + }, + "ui_composer_toolbar": { + "title": "在撰写窗口中添加按钮", + "body": "在您撰写邮件时显示它自己的按钮。" + }, + "ui_composer_sidebar": { + "title": "在撰写窗口中添加面板", + "body": "在您撰写的邮件旁边显示它自己的面板。" + }, + "ui_sidebar_widget": { + "title": "在侧边栏中添加方框", + "body": "在邮件侧边栏中显示它自己的内容。" + }, + "ui_settings_section": { + "title": "添加设置页面", + "body": "在设置中显示它自己的页面。" + }, + "ui_context_menu": { + "title": "添加到邮件菜单", + "body": "在右键点击邮件时打开的菜单中添加条目。" + }, + "ui_navigation_rail": { + "title": "添加到导航栏", + "body": "在导航栏中显示它自己的按钮。" + }, + "ui_keyboard": { + "title": "添加键盘快捷键", + "body": "响应它定义的键盘快捷键。" + }, + "ui_calendar_action": { + "title": "为活动添加按钮", + "body": "在您创建或编辑活动时显示它自己的按钮。" + }, + "ui_admin_page": { + "title": "添加管理页面", + "body": "在管理设置中显示它自己的页面。" + }, + "app_lifecycle": { + "title": "得知应用何时启动和停止", + "body": "在应用启动、离线或您退出登录时收到通知。" + } } }, "global_search": { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 2686a8b7a..14dc04589 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -11,6 +11,8 @@ import { useLocaleStore } from '@/stores/locale-store'; import { getEffectiveLocale } from '@/i18n/detect-locale'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; import { requestConsent } from '@/lib/plugin-sandbox/consent'; +import { packageHash, type ValidatedPluginManifest } from '@/lib/extension-package'; +import JSZip from 'jszip'; import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity'; import { downloadManagedBundle } from '@/lib/plugin-sandbox/bundle-fetch'; import { usePolicyStore } from '@/stores/policy-store'; @@ -25,12 +27,24 @@ let localeSubscribed = false; // ─── Store Interface ───────────────────────────────────────── +/** + * `reason` names why an install was refused so the settings page can say so + * in the user's language; `error` carries the validator's details. + */ +export interface InstallResult { + success: boolean; + reason?: 'invalid' | 'uploads-disabled' | 'managed-id'; + error?: string; + warnings?: string[]; + approvalStatus?: InstalledPlugin['approvalStatus']; +} + interface PluginStoreState { plugins: InstalledPlugin[]; initialized: boolean; // Management - installPlugin: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>; + installPlugin: (file: File) => Promise; uninstallPlugin: (id: string) => void; enablePlugin: (id: string) => Promise; disablePlugin: (id: string) => void; @@ -52,9 +66,13 @@ export const usePluginStore = create()( initialized: false, installPlugin: async (file: File) => { + const policy = usePolicyStore.getState(); + if (!policy.isFeatureEnabled('pluginsUploadEnabled')) { + return { success: false, reason: 'uploads-disabled' }; + } const result = await extractPlugin(file); if (!result.valid || !result.manifest) { - return { success: false, error: result.errors.join('; '), warnings: result.warnings }; + return { success: false, reason: 'invalid', error: result.errors.join('; '), warnings: result.warnings }; } const { manifest, code } = result; @@ -62,6 +80,12 @@ export const usePluginStore = create()( // Check for duplicate const existing = plugins.find(p => p.id === manifest.id); + // An admin-deployed plugin owns its id. A user package under the same + // id would inherit its "managed" trust (no consent, maybe the + // privileged tier) the next time the record is marked managed. + if (existing?.managed || getServerManagedPluginIds().has(manifest.id)) { + return { success: false, reason: 'managed-id' }; + } if (existing) { // Update: deactivate old, replace deactivatePlugin(manifest.id); @@ -70,6 +94,7 @@ export const usePluginStore = create()( // Compute bundleHash so the admin-approval gate can pin to this // specific bundle (server-side state keys on (id, hash) pairs). const bundleHash = await sha256Hex(code).catch(() => undefined); + const approvalHash = await packageHash(manifest as ValidatedPluginManifest, code); const plugin: InstalledPlugin = { id: manifest.id, @@ -90,6 +115,8 @@ export const usePluginStore = create()( settingsSchema: manifest.settingsSchema, ...(manifest.locales ? { locales: manifest.locales } : {}), ...(bundleHash ? { bundleHash } : {}), + packageManifest: manifest as unknown as Record, + approvalHash, ...(manifest.httpOrigins && manifest.httpOrigins.length > 0 ? { httpOrigins: manifest.httpOrigins } : {}), @@ -101,13 +128,17 @@ export const usePluginStore = create()( // Save code to IndexedDB await pluginStorage.saveCode(manifest.id, code); + if (policy.isFeatureEnabled('requirePluginApproval')) { + plugin.approvalStatus = await submitApprovalRequest(plugin, code); + } + if (existing) { set({ plugins: plugins.map(p => p.id === manifest.id ? plugin : p) }); } else { set({ plugins: [...plugins, plugin] }); } - return { success: true, warnings: result.warnings }; + return { success: true, warnings: result.warnings, approvalStatus: plugin.approvalStatus }; }, uninstallPlugin: (id: string) => { @@ -144,35 +175,9 @@ export const usePluginStore = create()( const plugin = plugins.find(p => p.id === id); if (!plugin) return; - // Admin approval gate. Managed (admin-pushed) plugins are pre- - // approved. For user-installed plugins the server-side state is - // authoritative: the client-only `isPluginApproved` flag is kept as - // a fast-path hint but the server result wins. - const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval'); - const policyApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id); - if (requireApproval && !policyApproved && plugin.bundleHash) { - const status = await checkServerApproval(plugin.id, plugin.bundleHash).catch(() => null); - if (status?.status === 'approved') { - // Approval available; proceed. - } else if (status?.status === 'denied') { - set(state => ({ - plugins: state.plugins.map(p => - p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Plugin denied by administrator' } : p - ), - })); - return; - } else { - // 'pending' or 'not-requested' - submit a request and refuse to enable. - await submitApprovalRequest(plugin).catch(() => { /* best effort */ }); - set(state => ({ - plugins: state.plugins.map(p => - p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Awaiting administrator approval' } : p - ), - })); - return; - } - } else if (requireApproval && !policyApproved) { - // No bundleHash means we can't pin the approval - refuse. + // Admin approval gate for plugins the user installed. The server + // holds the decision, pinned to the package hash (code + manifest). + if (!plugin.managed && !(await userPluginMayRun(plugin, get, set, { request: true }))) { return; } @@ -185,7 +190,10 @@ export const usePluginStore = create()( .filter((p): p is Permission => !!p) .filter((p) => !implicit.has(p) && !granted.has(p)); if (missing.length > 0 && !plugin.managed) { - const accepted = await requestConsent(plugin.id, plugin.name, missing as Permission[]); + const accepted = await requestConsent(plugin.id, plugin.name, missing as Permission[], { + httpOrigins: plugin.httpOrigins, + apiPostPaths: plugin.apiPostPaths, + }); if (!accepted) return; // Persist the grants so future enables don't re-prompt. const allGranted = [...new Set([...granted, ...missing])]; @@ -297,7 +305,11 @@ export const usePluginStore = create()( // hung/slow plugin block every subsequent one; loadSandboxedPlugin // catches its own errors so allSettled is just for tidy completion. const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error'); - await Promise.allSettled(enabledPlugins.map(plugin => loadPlugin(plugin))); + // A plugin the user installed is checked again on every start: the + // admin may have revoked its approval or turned user plugins off. + const allowed = await Promise.all(enabledPlugins.map(p => + p.managed ? true : userPluginMayRun(p, get, set, { request: false }))); + await Promise.allSettled(enabledPlugins.filter((_, i) => allowed[i]).map(plugin => loadPlugin(plugin))); set({ initialized: true }); })(); @@ -582,27 +594,69 @@ async function checkServerApproval(pluginId: string, bundleHash: string): Promis } } -async function submitApprovalRequest(plugin: InstalledPlugin): Promise { - if (!plugin.bundleHash) return; +/** + * Send the package to the server for an admin to review. The server reads + * the manifest and code from the ZIP and pins the request to the hash it + * computes itself. Returns the resulting status, or undefined when the + * server could not be reached. + */ +async function submitApprovalRequest( + plugin: InstalledPlugin, + code?: string, +): Promise { + if (!plugin.packageManifest) return undefined; try { - await apiFetch('/api/plugin-approval-status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - pluginId: plugin.id, - bundleHash: plugin.bundleHash, - manifest: { - name: plugin.name, - version: plugin.version, - author: plugin.author, - description: plugin.description, - permissions: plugin.permissions, - httpOrigins: plugin.httpOrigins, - apiPostPaths: plugin.apiPostPaths, - }, - }), - }); + const source = code ?? await pluginStorage.getCode(plugin.id); + if (!source) return undefined; + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(plugin.packageManifest)); + zip.file(String(plugin.packageManifest.entrypoint ?? plugin.entrypoint), source); + const form = new FormData(); + form.append('file', await zip.generateAsync({ type: 'blob' }), `${plugin.id}.zip`); + const res = await apiFetch('/api/plugin-approval-status', { method: 'POST', body: form }); + if (!res.ok) return undefined; + const data = await res.json() as { status?: InstalledPlugin['approvalStatus'] }; + return data.status; } catch { - /* best effort */ + return undefined; } } + +/** + * Whether a plugin the user installed may run now: user plugins must be + * allowed by policy, and when approval is required the server must have + * approved this exact package. Records the outcome on the plugin so the + * settings page can explain it. With `request`, an unknown package is sent + * for review. + */ +async function userPluginMayRun( + plugin: InstalledPlugin, + get: () => PluginStoreState, + set: (partial: Partial | ((state: PluginStoreState) => Partial)) => void, + opts: { request: boolean }, +): Promise { + const policy = usePolicyStore.getState(); + const mark = (patch: Partial) => set(state => ({ + plugins: state.plugins.map(p => (p.id === plugin.id ? { ...p, ...patch } : p)), + })); + if (!policy.isFeatureEnabled('pluginsUploadEnabled')) { + mark({ status: 'error', error: 'user-plugins-off' }); + return false; + } + if (!policy.isFeatureEnabled('requirePluginApproval')) return true; + if (!plugin.approvalHash) { + mark({ status: 'error', error: 'approval-unavailable' }); + return false; + } + let status = (await checkServerApproval(plugin.id, plugin.approvalHash))?.status; + if (opts.request && (status === 'not-requested' || status === undefined)) { + status = await submitApprovalRequest(plugin) ?? status; + } + if (status === 'approved') { + mark({ approvalStatus: 'approved' }); + return true; + } + mark({ approvalStatus: status, status: 'error', error: status === 'denied' ? 'approval-denied' : 'approval-pending' }); + void get; + return false; +} diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 146fd6719..d3870f969 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -25,7 +25,6 @@ interface PolicyState { getForcedThemeId: (availableThemeIds?: string[]) => string | null; isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean; isPluginForceEnabled: (pluginId: string) => boolean; - isPluginApproved: (pluginId: string) => boolean; isThemeForceEnabled: (themeId: string) => boolean; } @@ -116,10 +115,6 @@ export const usePolicyStore = create()((set, get) => ({ return (get().policy.forceEnabledPlugins || []).includes(pluginId); }, - isPluginApproved: (pluginId) => { - return (get().policy.approvedPlugins || []).includes(pluginId); - }, - isThemeForceEnabled: (themeId) => { return (get().policy.forceEnabledThemes || []).includes(themeId); }, From 592db209db1e885588c377147ed84cf2333ae1ea Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:05:47 +0200 Subject: [PATCH 008/147] fix: sign plugin bundles when they are installed and require the signature for privileged plugins --- app/api/admin/plugins/[id]/bundle/route.ts | 4 +- lib/__tests__/plugin-bundle-fetch.test.ts | 8 +++ lib/__tests__/plugin-sandbox-loader.test.ts | 4 +- lib/__tests__/plugin-store.test.ts | 6 +-- .../plugin-registry-signature.test.ts | 54 +++++++++++++++++++ lib/admin/plugin-registry.ts | 40 ++++++++++++-- lib/plugin-sandbox/bundle-fetch.ts | 10 +++- lib/plugin-sandbox/loader.ts | 4 +- lib/plugin-sandbox/tier.ts | 31 ++++------- stores/plugin-store.ts | 8 +-- 10 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 lib/admin/__tests__/plugin-registry-signature.test.ts diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index f2a4fc687..cb335a101 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; +import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { signBytes } from '@/lib/admin/plugin-signing'; @@ -65,7 +65,7 @@ export async function GET( }; if (etag) headers['ETag'] = etag; - const signature = await safeSign(code); + const signature = await getPluginSignature(id, code); if (signature) headers['X-Bundle-Signature'] = signature; if (etag && request.headers.get('if-none-match') === etag) { diff --git a/lib/__tests__/plugin-bundle-fetch.test.ts b/lib/__tests__/plugin-bundle-fetch.test.ts index 17bcf11f8..bac7bb5de 100644 --- a/lib/__tests__/plugin-bundle-fetch.test.ts +++ b/lib/__tests__/plugin-bundle-fetch.test.ts @@ -59,6 +59,14 @@ describe('downloadManagedBundle', () => { expect(console.warn).toHaveBeenCalled(); }); + it('refuses an unsigned bundle for a plugin that must be signed', async () => { + // Privileged plugins run with the app's own rights; without the + // install-time signature anyone who can write the bundle file decides + // what they run. + mocks.apiFetch.mockResolvedValue(response('code')); + await expect(downloadManagedBundle('my-plugin', 'abc', { requireSignature: true })).rejects.toThrow(/not signed/); + }); + it('reports an HTTP failure with the status', async () => { mocks.apiFetch.mockResolvedValue(response('', { ok: false, status: 404 })); await expect(downloadManagedBundle('my-plugin')).rejects.toThrow(/HTTP 404/); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 9a2bd91cc..307a5f2a5 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -104,7 +104,7 @@ describe('loadSandboxedPlugin bundle sourcing', () => { mocks.getCode.mockResolvedValue(null); mocks.downloadManagedBundle.mockResolvedValue(CODE); await loadSandboxedPlugin(plugin()); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH, { requireSignature: false }); expect(mocks.saveCode).toHaveBeenCalledWith('managed-plugin', CODE); expect(lastStatus()).toMatchObject({ status: 'running' }); expect(getActive('managed-plugin')?.code).toBe(CODE); @@ -141,7 +141,7 @@ describe('loadSandboxedPlugin bundle sourcing', () => { it('accepts a managed bundle without a recorded hash (older records)', async () => { mocks.downloadManagedBundle.mockResolvedValue(CODE); await loadSandboxedPlugin(plugin({ bundleHash: undefined })); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined, { requireSignature: false }); expect(lastStatus()).toMatchObject({ status: 'running' }); }); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 5b3d5a94a..5d3b4a56f 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -171,7 +171,7 @@ describe('usePluginStore', () => { it('installs a new server plugin and caches its bundle', async () => { await usePluginStore.getState().initializePlugins(); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1'); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false }); expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code'); const p = usePluginStore.getState().plugins.find(x => x.id === 'managed-plugin'); expect(p).toMatchObject({ managed: true, enabled: true, bundleHash: 'hash-v1' }); @@ -183,7 +183,7 @@ describe('usePluginStore', () => { await usePluginStore.getState().initializePlugins(); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1'); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false }); expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code'); expect(usePluginStore.getState().plugins).toHaveLength(1); }); @@ -204,7 +204,7 @@ describe('usePluginStore', () => { await usePluginStore.getState().initializePlugins(); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1'); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false }); expect(usePluginStore.getState().plugins[0].bundleHash).toBe('hash-v1'); }); diff --git a/lib/admin/__tests__/plugin-registry-signature.test.ts b/lib/admin/__tests__/plugin-registry-signature.test.ts new file mode 100644 index 000000000..248747836 --- /dev/null +++ b/lib/admin/__tests__/plugin-registry-signature.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPublicKey, verify } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-registry-')); + vi.stubEnv('ADMIN_CONFIG_DIR', dir); + vi.resetModules(); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +async function verifies(code: string, signature: string): Promise { + const pem = await readFile(path.join(dir, 'plugin-signing.key'), 'utf-8'); + const publicKey = createPublicKey(pem); + return verify(null, Buffer.from(code, 'utf-8'), publicKey, Buffer.from(signature, 'base64')); +} + +const PLUGIN = { + id: 'signed-demo', name: 'S', version: '1.0.0', author: 'a', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, installedAt: 'x', updatedAt: 'x', +}; + +describe('plugin bundle signatures', () => { + it('signs the bundle when it is installed', async () => { + const registry = await import('../plugin-registry'); + await registry.savePlugin(PLUGIN, 'module.exports = {}'); + + const signature = await registry.getPluginSignature('signed-demo', 'module.exports = {}'); + + expect(signature).toBeTruthy(); + expect(await verifies('module.exports = {}', signature!)).toBe(true); + }); + + it('keeps the install-time signature when the file on disk changes', async () => { + // Signing at serve time signed whatever was on disk, so a swapped + // bundle still came with a valid signature. + const registry = await import('../plugin-registry'); + await registry.savePlugin(PLUGIN, 'module.exports = {}'); + await writeFile(path.join(dir, 'plugins', 'signed-demo.js'), 'stealEverything()', 'utf-8'); + + const swapped = (await registry.getPluginBundle('signed-demo'))!; + const signature = await registry.getPluginSignature('signed-demo', swapped); + + expect(await verifies(swapped, signature!)).toBe(false); + }); +}); diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 49bc072d7..4f1a7a03e 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -205,6 +205,10 @@ export async function savePlugin( // Save code bundle const bundlePath = path.join(dir, `${plugin.id}.js`); await writeFile(bundlePath, code, 'utf-8'); + // Sign now and keep the signature with the bundle. Signing at serve time + // would sign whatever is on disk, so a bundle file swapped after install + // would still verify. + await storePluginSignature(plugin.id, code); // Stamp content hash + updatedAt so clients can detect re-uploads even // when the manifest version hasn't changed. Preserve the original @@ -249,13 +253,43 @@ export async function deletePlugin(id: string): Promise { registry.plugins.splice(idx, 1); await writeJsonFile(pluginRegistryPath(), registry); - // Remove bundle file - const bundlePath = path.join(getPluginsDir(), `${id}.js`); - try { await unlink(bundlePath); } catch { /* ok if missing */ } + // Remove bundle and signature files + for (const file of [`${id}.js`, `${id}.sig`]) { + try { await unlink(path.join(getPluginsDir(), file)); } catch { /* ok if missing */ } + } return true; } +async function storePluginSignature(id: string, code: string): Promise { + const sigPath = path.join(getPluginsDir(), `${id}.sig`); + try { + const { signBytes } = await import('./plugin-signing'); + const signature = await signBytes(code); + await writeFile(sigPath, signature, 'utf-8'); + return signature; + } catch (err) { + // Without a key the bundle still installs; it just can't run privileged. + try { await unlink(sigPath); } catch { /* none */ } + logger.warn('[plugin-registry] could not sign plugin bundle', { id, error: err instanceof Error ? err.message : String(err) }); + return null; + } +} + +/** + * The signature made when the bundle was installed. Bundles installed before + * signatures were stored get one on first request (trust on first use), so + * a later change to the file on disk stops verifying from then on. + */ +export async function getPluginSignature(id: string, code: string): Promise { + if (!SAFE_ID_RE.test(id)) return null; + try { + return (await readFile(path.join(getPluginsDir(), `${id}.sig`), 'utf-8')).trim() || null; + } catch { + return storePluginSignature(id, code); + } +} + export async function getPluginBundle(id: string): Promise { const bundlePath = path.join(getPluginsDir(), `${id}.js`); try { diff --git a/lib/plugin-sandbox/bundle-fetch.ts b/lib/plugin-sandbox/bundle-fetch.ts index 031fe2cc8..fb9b90619 100644 --- a/lib/plugin-sandbox/bundle-fetch.ts +++ b/lib/plugin-sandbox/bundle-fetch.ts @@ -28,7 +28,11 @@ export function bundleUrl(pluginId: string, bundleHash?: string | null): string * load-time error shows up on the plugin card instead of the plugin silently * vanishing from the list. */ -export async function downloadManagedBundle(pluginId: string, bundleHash?: string | null): Promise { +export async function downloadManagedBundle( + pluginId: string, + bundleHash?: string | null, + opts: { requireSignature?: boolean } = {}, +): Promise { let res: Response; try { res = await apiFetch(bundleUrl(pluginId, bundleHash)); @@ -55,6 +59,10 @@ export async function downloadManagedBundle(pluginId: string, bundleHash?: strin if (!ok) { throw new Error(`Refusing the bundle for plugin "${pluginId}": signature verification failed`); } + } else if (opts.requireSignature) { + // A privileged plugin runs with full access to the app; it only loads + // with a signature made when the admin installed it. + throw new Error(`Refusing the bundle for plugin "${pluginId}": it is not signed`); } else { console.warn(`[plugin-bundle] Bundle for "${pluginId}" has no Ed25519 signature; loading without it`); } diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index e5f8b4706..9172915e7 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -100,7 +100,9 @@ async function getBundleCode(plugin: InstalledPlugin): Promise { throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`); } - const code = await downloadManagedBundle(plugin.id, plugin.bundleHash); + const code = await downloadManagedBundle(plugin.id, plugin.bundleHash, { + requireSignature: plugin.tier === 'privileged', + }); await verifyBundle(code, plugin.bundleHash); try { await pluginStorage.saveCode(plugin.id, code); diff --git a/lib/plugin-sandbox/tier.ts b/lib/plugin-sandbox/tier.ts index 2861e2b91..1c92777fc 100644 --- a/lib/plugin-sandbox/tier.ts +++ b/lib/plugin-sandbox/tier.ts @@ -23,14 +23,10 @@ export type TierResolution = * * Privileged tier gates (ALL required): * 1. Manifest declares the umbrella high-risk permission `crypto:full`. - * 2. Signed bundle: only bundles delivered through the admin/server channel - * are Ed25519-signed (verified at download time — see `verifySignature` - * usage in the plugin store). Self-uploaded bundles are unsigned and can - * therefore never reach the privileged tier. `managed` is the signal that - * the bundle came through that signed channel. - * 3. Admin approval pins operator trust in this specific bundle. - * 4. Explicit high-risk consent for `crypto:full` (granted via the consent - * dialog at enable time; admin-managed plugins are pre-approved). + * 2. An admin installed it (`managed`). Admin installs are the operator's + * decision, so there is no separate approval or per-user consent step. + * 3. Its bundle verifies against the signature made at install time; the + * download refuses an unsigned or mis-signed privileged bundle. */ export function resolvePluginTier(plugin: InstalledPlugin): TierResolution { if (plugin.tier !== 'privileged') { @@ -42,23 +38,14 @@ export function resolvePluginTier(plugin: InstalledPlugin): TierResolution { return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' }; } - // 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle - // uploaded by the user directly carries no signature, so it cannot be - // privileged regardless of what its manifest claims. + // 2. Only an admin can install a privileged plugin. A plugin the user + // uploaded is never privileged, whatever its manifest claims; installing + // over an admin plugin's id is refused (see installPlugin). The bundle + // itself must carry the signature made at admin install time, which the + // bundle download checks for privileged plugins (downloadManagedBundle). if (!plugin.managed) { return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' }; } - if (!(plugin.adminApproved || plugin.managed)) { - return { tier: null, error: 'Privileged tier requires administrator approval' }; - } - - // 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by - // the operator and skip the per-user prompt (mirrors the existing consent - // gate in the plugin store); otherwise the user must have granted crypto:full. - const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full'); - if (!consented) { - return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' }; - } return { tier: 'privileged' }; } diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 14dc04589..28fcd32fd 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -476,7 +476,7 @@ async function syncServerPlugins( if (!local) { // New server plugin - download bundle and install. - const code = await downloadPluginBundle(sp.id, sp.bundleHash); + const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); if (!code) continue; await pluginStorage.saveCode(sp.id, code); @@ -521,7 +521,7 @@ async function syncServerPlugins( (!!sp.bundleHash && local.bundleHash !== sp.bundleHash); if (needsBundle) { - const code = await downloadPluginBundle(sp.id, sp.bundleHash); + const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); if (!code) continue; await pluginStorage.saveCode(sp.id, code); } @@ -572,9 +572,9 @@ async function syncServerPlugins( * The sandbox loader shares the same download path * (`lib/plugin-sandbox/bundle-fetch`) to refill a missing bundle at load time. */ -async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise { +async function downloadPluginBundle(pluginId: string, bundleHash?: string, tier?: PluginTier): Promise { try { - return await downloadManagedBundle(pluginId, bundleHash); + return await downloadManagedBundle(pluginId, bundleHash, { requireSignature: tier === 'privileged' }); } catch (err) { console.error(`[plugin-store] ${err instanceof Error ? err.message : String(err)}`); return null; From be3debdcb4522d75e8f597198d0acec256ded1e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:11:59 +0200 Subject: [PATCH 009/147] fix: never send a message when a plugin that checks it before sending fails or does not answer --- .../composer-account-switch.test.tsx | 2 +- .../composer-attach-from-files.test.tsx | 2 +- .../composer-attachment-list-scroll.test.tsx | 2 +- .../__tests__/composer-close-guard.test.tsx | 2 +- .../email/__tests__/composer-discard.test.tsx | 2 +- .../composer-draft-attachments.test.tsx | 2 +- .../__tests__/composer-format-toggle.test.tsx | 2 +- .../composer-forward-other-account.test.tsx | 2 +- .../composer-from-override-envelope.test.tsx | 2 +- .../email/__tests__/composer-mention.test.tsx | 2 +- .../composer-mobile-formatting.test.tsx | 2 +- .../__tests__/composer-mv-window.test.tsx | 2 +- ...composer-plugin-attachment-source.test.tsx | 2 +- .../__tests__/composer-plugin-send.test.tsx | 2 +- .../composer-recipient-validation.test.tsx | 2 +- ...composer-reopened-draft-signature.test.tsx | 2 +- .../composer-reply-draft-threading.test.tsx | 2 +- .../__tests__/composer-reply-focus.test.tsx | 2 +- .../composer-schedule-presets.test.tsx | 2 +- .../__tests__/composer-send-errors.test.tsx | 2 +- .../__tests__/composer-send-unmount.test.tsx | 2 +- .../__tests__/composer-short-pane.test.tsx | 2 +- .../email/__tests__/composer-title.test.tsx | 2 +- .../composer-upload-account.test.tsx | 2 +- .../__tests__/empty-subject-warning.test.tsx | 2 +- components/email/email-composer.tsx | 9 ++- lib/__tests__/plugin-hooks.test.ts | 45 ++++++++++++++- lib/plugin-hooks.ts | 56 ++++++++++++++++--- lib/plugin-sandbox/loader.ts | 3 +- locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 56 files changed, 178 insertions(+), 66 deletions(-) diff --git a/components/email/__tests__/composer-account-switch.test.tsx b/components/email/__tests__/composer-account-switch.test.tsx index 5da9297d0..9914ff30b 100644 --- a/components/email/__tests__/composer-account-switch.test.tsx +++ b/components/email/__tests__/composer-account-switch.test.tsx @@ -147,7 +147,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx index 938a612f9..db41fe94f 100644 --- a/components/email/__tests__/composer-attach-from-files.test.tsx +++ b/components/email/__tests__/composer-attach-from-files.test.tsx @@ -158,7 +158,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx index 9efbceeb6..76b508546 100644 --- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx +++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx @@ -139,7 +139,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ getRecipientSuggestions: { call: async () => [] }, onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { intercept: async () => true }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 85cec8f86..7c0b892ff 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -141,7 +141,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-discard.test.tsx b/components/email/__tests__/composer-discard.test.tsx index f391bd06c..9fa0f28dd 100644 --- a/components/email/__tests__/composer-discard.test.tsx +++ b/components/email/__tests__/composer-discard.test.tsx @@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index 259e74132..d14cf789a 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -139,7 +139,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx index 8b64cbf66..b70d7bbac 100644 --- a/components/email/__tests__/composer-format-toggle.test.tsx +++ b/components/email/__tests__/composer-format-toggle.test.tsx @@ -144,7 +144,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-forward-other-account.test.tsx b/components/email/__tests__/composer-forward-other-account.test.tsx index 4c310c81a..a0957a0b0 100644 --- a/components/email/__tests__/composer-forward-other-account.test.tsx +++ b/components/email/__tests__/composer-forward-other-account.test.tsx @@ -150,7 +150,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx index 93b1a94a7..b203296ed 100644 --- a/components/email/__tests__/composer-from-override-envelope.test.tsx +++ b/components/email/__tests__/composer-from-override-envelope.test.tsx @@ -143,7 +143,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index 28cceeb20..7b0c84ae1 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-mobile-formatting.test.tsx b/components/email/__tests__/composer-mobile-formatting.test.tsx index ca1ffeb69..bc58092c1 100644 --- a/components/email/__tests__/composer-mobile-formatting.test.tsx +++ b/components/email/__tests__/composer-mobile-formatting.test.tsx @@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-mv-window.test.tsx b/components/email/__tests__/composer-mv-window.test.tsx index 5b0407095..9039805f8 100644 --- a/components/email/__tests__/composer-mv-window.test.tsx +++ b/components/email/__tests__/composer-mv-window.test.tsx @@ -146,7 +146,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-plugin-attachment-source.test.tsx b/components/email/__tests__/composer-plugin-attachment-source.test.tsx index f07a6f80a..55a8cb17d 100644 --- a/components/email/__tests__/composer-plugin-attachment-source.test.tsx +++ b/components/email/__tests__/composer-plugin-attachment-source.test.tsx @@ -162,7 +162,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-plugin-send.test.tsx b/components/email/__tests__/composer-plugin-send.test.tsx index 0d5b95449..bd9b1fbf8 100644 --- a/components/email/__tests__/composer-plugin-send.test.tsx +++ b/components/email/__tests__/composer-plugin-send.test.tsx @@ -148,7 +148,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: hoisted.takeOver }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-recipient-validation.test.tsx b/components/email/__tests__/composer-recipient-validation.test.tsx index f7815d517..a9bb763f3 100644 --- a/components/email/__tests__/composer-recipient-validation.test.tsx +++ b/components/email/__tests__/composer-recipient-validation.test.tsx @@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-reopened-draft-signature.test.tsx b/components/email/__tests__/composer-reopened-draft-signature.test.tsx index ea61f85dc..97f25953e 100644 --- a/components/email/__tests__/composer-reopened-draft-signature.test.tsx +++ b/components/email/__tests__/composer-reopened-draft-signature.test.tsx @@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-reply-draft-threading.test.tsx b/components/email/__tests__/composer-reply-draft-threading.test.tsx index a820821b5..3ba0edceb 100644 --- a/components/email/__tests__/composer-reply-draft-threading.test.tsx +++ b/components/email/__tests__/composer-reply-draft-threading.test.tsx @@ -145,7 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-reply-focus.test.tsx b/components/email/__tests__/composer-reply-focus.test.tsx index 545440f3d..b7d4ccacc 100644 --- a/components/email/__tests__/composer-reply-focus.test.tsx +++ b/components/email/__tests__/composer-reply-focus.test.tsx @@ -152,7 +152,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-schedule-presets.test.tsx b/components/email/__tests__/composer-schedule-presets.test.tsx index 32c9697ea..07bcc2f0b 100644 --- a/components/email/__tests__/composer-schedule-presets.test.tsx +++ b/components/email/__tests__/composer-schedule-presets.test.tsx @@ -150,7 +150,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-send-errors.test.tsx b/components/email/__tests__/composer-send-errors.test.tsx index 1f2a4fba0..13e53fd23 100644 --- a/components/email/__tests__/composer-send-errors.test.tsx +++ b/components/email/__tests__/composer-send-errors.test.tsx @@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-send-unmount.test.tsx b/components/email/__tests__/composer-send-unmount.test.tsx index d2143cf78..2aac397d1 100644 --- a/components/email/__tests__/composer-send-unmount.test.tsx +++ b/components/email/__tests__/composer-send-unmount.test.tsx @@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-short-pane.test.tsx b/components/email/__tests__/composer-short-pane.test.tsx index e37e11aa9..243415f75 100644 --- a/components/email/__tests__/composer-short-pane.test.tsx +++ b/components/email/__tests__/composer-short-pane.test.tsx @@ -139,7 +139,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ getRecipientSuggestions: { call: async () => [] }, onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-title.test.tsx b/components/email/__tests__/composer-title.test.tsx index 729a5eace..78fbcbd1d 100644 --- a/components/email/__tests__/composer-title.test.tsx +++ b/components/email/__tests__/composer-title.test.tsx @@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/__tests__/composer-upload-account.test.tsx b/components/email/__tests__/composer-upload-account.test.tsx index 3b8baf88a..229912d63 100644 --- a/components/email/__tests__/composer-upload-account.test.tsx +++ b/components/email/__tests__/composer-upload-account.test.tsx @@ -151,7 +151,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, onBeforeAttachmentUpload: { intercept: async () => true }, diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index c8a020628..a62b67891 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -149,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, - onBeforeEmailSend: { intercept: async () => true }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fe3f3160d..664a93611 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2623,11 +2623,14 @@ export function EmailComposer({ .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], }; - const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); - if (!sendAllowed) { + const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview); + if (sendCheck !== 'allowed') { // A plugin vetoed the send (it is expected to show its own UI). // Leave a trace so a silent no-op send is diagnosable (#592). - debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler'); + debug.log('email', `Send stopped by an onBeforeEmailSend plugin handler (${sendCheck})`); + // A guard that crashed or never answered showed nothing; say why + // the message is still here. + if (sendCheck === 'failed') toast.error(t('send_guard_failed')); return; } diff --git a/lib/__tests__/plugin-hooks.test.ts b/lib/__tests__/plugin-hooks.test.ts index a90cc4226..94f65ceea 100644 --- a/lib/__tests__/plugin-hooks.test.ts +++ b/lib/__tests__/plugin-hooks.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult } from '../plugin-hooks'; +import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult, setHookTimeoutListener } from '../plugin-hooks'; import type { CalendarEventFormView } from '../plugin-types'; beforeEach(() => { @@ -417,3 +417,46 @@ describe('clearAllHooks', () => { expect(calendarHooks.onCalendarEventOpen.size).toBe(0); }); }); + +describe('onBeforeEmailSend fails closed', () => { + it('does not send when the plugin never answers', async () => { + // A plugin that shows a confirm and waits for the user used to let the + // mail go out after 60 s, with the dialog still on screen. + vi.useFakeTimers(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + emailHooks.onBeforeEmailSend.register('guard', () => new Promise(() => {})); + const result = emailHooks.onBeforeEmailSend.intercept({}); + await vi.advanceTimersByTimeAsync(10 * 60_000 + 100); + expect(await result).toBe(false); + } finally { + consoleSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it('does not send when the plugin throws', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); }); + expect(await emailHooks.onBeforeEmailSend.intercept({})).toBe(false); + consoleSpy.mockRestore(); + }); + + it('closes the dialogs of a plugin whose hook ran out of time', async () => { + vi.useFakeTimers(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onTimeout = vi.fn(); + setHookTimeoutListener(onTimeout); + try { + emailHooks.onBeforeReply.register('slow', () => new Promise(() => {})); + const result = emailHooks.onBeforeReply.intercept({}); + await vi.advanceTimersByTimeAsync(60_000 + 100); + expect(await result).toBe(true); + expect(onTimeout).toHaveBeenCalledWith('slow'); + } finally { + setHookTimeoutListener(null); + consoleSpy.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index d9206e978..58b2ffd38 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -69,10 +69,23 @@ const INTERCEPT_TIMEOUT_MS = 60_000; // offloading, and three of those in a minute would auto-disable the plugin. const BLOB_UPLOAD_TIMEOUT_MS = 120_000; +class HookTimeoutError extends Error {} + +let timeoutListener: ((pluginId: string) => void) | null = null; + +/** + * Called with the plugin id whenever one of its handlers runs out of time. + * The loader uses it to close that plugin's open dialogs: the host has + * already moved on, so a dialog left on screen would answer nothing. + */ +export function setHookTimeoutListener(listener: ((pluginId: string) => void) | null): void { + timeoutListener = listener; +} + function withTimeout(promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS): Promise { if (!(promise instanceof Promise)) return Promise.resolve(promise); return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); + const timer = setTimeout(() => reject(new HookTimeoutError(`Hook timed out after ${ms}ms`)), ms); promise.then( (val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); }, @@ -115,8 +128,21 @@ export class HookBus any> { * Defaults to 5s; a bus whose handlers legitimately do network work sets * its own (see `onBeforeBlobUpload`). `intercept` keeps its own longer * budget because those hooks block on user dialogs. + * @param opts.failClosed For intercepts guarding an action that can't be + * taken back (sending mail): a handler that throws or runs out of time + * cancels the action instead of letting it through. */ - constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} + constructor( + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, + private readonly opts: { failClosed?: boolean } = {}, + ) {} + + private recordFailure(pluginId: string, err: unknown): void { + pluginErrorTracker.record(pluginId, err); + if (err instanceof HookTimeoutError) { + try { timeoutListener?.(pluginId); } catch { /* ignore */ } + } + } register(pluginId: string, handler: T, order: number = 100): Disposable { const entry: HookEntry = { pluginId, handler, order }; @@ -150,7 +176,7 @@ export class HookBus any> { try { await withTimeout(handler(...args), this.timeoutMs); } catch (err) { - pluginErrorTracker.record(pluginId, err); + this.recordFailure(pluginId, err); } } } @@ -169,16 +195,26 @@ export class HookBus any> { /** Fire handlers as interceptors - any returning false cancels the operation */ async intercept(...args: Parameters): Promise { + return (await this.interceptOutcome(...args)) === 'allowed'; + } + + /** + * Like `intercept`, but says why an operation was stopped: `vetoed` when a + * handler said no (it is expected to have told the user), `failed` when a + * fail-closed bus stopped it because a handler threw or ran out of time. + */ + async interceptOutcome(...args: Parameters): Promise<'allowed' | 'vetoed' | 'failed'> { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { const result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS)); - if (result === false) return false; + if (result === false) return 'vetoed'; } catch (err) { - pluginErrorTracker.record(pluginId, err); + this.recordFailure(pluginId, err); + if (this.opts.failClosed) return 'failed'; } } - return true; + return 'allowed'; } /** @@ -194,7 +230,7 @@ export class HookBus any> { try { result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS)); } catch (err) { - pluginErrorTracker.record(pluginId, err); + this.recordFailure(pluginId, err); return 'failed'; } if (result === false) return 'handled'; @@ -214,7 +250,7 @@ export class HookBus any> { value = result as V; } } catch (err) { - pluginErrorTracker.record(pluginId, err); + this.recordFailure(pluginId, err); } } return value; @@ -274,7 +310,9 @@ export const emailHooks = { // Return false to cancel opening the composer. onBeforeCompose: new HookBus(), onComposerOpen: new HookBus(), - onBeforeEmailSend: new HookBus(), + // Fails closed with the long send budget: a guard that asks the user and + // gets no answer must not let the mail go out behind its dialog. + onBeforeEmailSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS, { failClosed: true }), onAfterEmailSend: new HookBus(), // Transform hook - fires before the draft is auto-saved to the server. // Receive fields passed to client.createDraft and may mutate fields in place. diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 9172915e7..a01b0f6a0 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -11,7 +11,7 @@ import { keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, messageListTabHooks, - removeAllPluginHooks, pluginErrorTracker, + removeAllPluginHooks, pluginErrorTracker, setHookTimeoutListener, } from '../plugin-hooks'; import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; import { verifyBundle } from './bundle-integrity'; @@ -262,6 +262,7 @@ export function deactivateAllSandboxed(): void { // ─── Auto-disable ───────────────────────────────────────────── export function setupSandboxAutoDisable(): void { + setHookTimeoutListener((pluginId) => cancelPluginDialogs(pluginId)); pluginErrorTracker.setAutoDisableCallback((pluginId) => { unloadSandboxedPlugin(pluginId); storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors'); diff --git a/locales/ar/common.json b/locales/ar/common.json index 97cc217a3..78a772c82 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -813,7 +813,8 @@ "remove_background_color": "إزالة لون الخلفية" }, "send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة.", - "pop_out": "فتح الرد في نافذة منفصلة" + "pop_out": "فتح الرد في نافذة منفصلة", + "send_guard_failed": "لم يُرسَل - فشلت إضافة تفحص الرسائل قبل الإرسال أو لم تستجب." }, "confirm_dialog": { "confirm": "تأكيد", diff --git a/locales/ca/common.json b/locales/ca/common.json index d4940d31f..43c1a1c50 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Elimina el color de fons" }, "send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet.", - "pop_out": "Obre la resposta en una finestra" + "pop_out": "Obre la resposta en una finestra", + "send_guard_failed": "No s'ha enviat: un connector que revisa els missatges abans d'enviar-los ha fallat o no ha respost." }, "confirm_dialog": { "confirm": "Confirma", diff --git a/locales/cs/common.json b/locales/cs/common.json index 677cc2e21..39adc31b6 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Odebrat barvu pozadí" }, "send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept.", - "pop_out": "Otevřít odpověď v okně" + "pop_out": "Otevřít odpověď v okně", + "send_guard_failed": "Neodesláno - plugin, který kontroluje zprávy před odesláním, selhal nebo neodpověděl." }, "confirm_dialog": { "confirm": "Potvrdit", diff --git a/locales/da/common.json b/locales/da/common.json index e6448e529..da431497a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Fjern baggrundsfarve" }, "send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående.", - "pop_out": "Åbn svaret i et vindue" + "pop_out": "Åbn svaret i et vindue", + "send_guard_failed": "Ikke sendt - et plugin, der kontrollerer beskeder før afsendelse, fejlede eller svarede ikke." }, "confirm_dialog": { "confirm": "Bekræft", diff --git a/locales/de/common.json b/locales/de/common.json index 3402006f8..1ea34da40 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Hintergrundfarbe entfernen" }, "send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar.", - "pop_out": "Antwort in eigenem Fenster öffnen" + "pop_out": "Antwort in eigenem Fenster öffnen", + "send_guard_failed": "Nicht gesendet - ein Plugin, das Nachrichten vor dem Senden prüft, ist fehlgeschlagen oder hat nicht geantwortet." }, "confirm_dialog": { "confirm": "Bestätigen", diff --git a/locales/en/common.json b/locales/en/common.json index f81c847c7..7f191767f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Remove background color" }, "send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain.", - "pop_out": "Pop out reply" + "pop_out": "Pop out reply", + "send_guard_failed": "Not sent - a plugin that checks messages before sending failed or did not answer." }, "confirm_dialog": { "confirm": "Confirm", diff --git a/locales/es/common.json b/locales/es/common.json index fb82b54e5..a3e2fb538 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Quitar color de fondo" }, "send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto.", - "pop_out": "Abrir la respuesta en una ventana" + "pop_out": "Abrir la respuesta en una ventana", + "send_guard_failed": "No enviado: un complemento que revisa los mensajes antes de enviarlos falló o no respondió." }, "confirm_dialog": { "confirm": "Confirmar", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4ac2702fe..243b2e1f0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -813,7 +813,8 @@ "remove_background_color": "حذف رنگ پس‌زمینه" }, "send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند.", - "pop_out": "باز کردن پاسخ در پنجره جدا" + "pop_out": "باز کردن پاسخ در پنجره جدا", + "send_guard_failed": "ارسال نشد - افزونه‌ای که پیام‌ها را پیش از ارسال بررسی می‌کند ناموفق بود یا پاسخ نداد." }, "confirm_dialog": { "confirm": "تأیید", diff --git a/locales/fr/common.json b/locales/fr/common.json index b40d92c1f..77f4388f3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Supprimer la couleur d'arrière-plan" }, "send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister.", - "pop_out": "Ouvrir la réponse dans une fenêtre" + "pop_out": "Ouvrir la réponse dans une fenêtre", + "send_guard_failed": "Non envoyé - un plugin qui vérifie les messages avant l'envoi a échoué ou n'a pas répondu." }, "confirm_dialog": { "confirm": "Confirmer", diff --git a/locales/he/common.json b/locales/he/common.json index b7ff75a9b..511925fe4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -778,7 +778,8 @@ "remove_background_color": "הסר צבע רקע" }, "send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה.", - "pop_out": "פתיחת התשובה בחלון נפרד" + "pop_out": "פתיחת התשובה בחלון נפרד", + "send_guard_failed": "לא נשלח - תוסף שבודק הודעות לפני השליחה נכשל או לא הגיב." }, "confirm_dialog": { "confirm": "אשר", diff --git a/locales/hu/common.json b/locales/hu/common.json index bac4f8302..46d7388b9 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Háttérszín eltávolítása" }, "send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat.", - "pop_out": "Válasz megnyitása külön ablakban" + "pop_out": "Válasz megnyitása külön ablakban", + "send_guard_failed": "Nem lett elküldve - egy bővítmény, amely küldés előtt ellenőrzi az üzeneteket, hibát jelzett vagy nem válaszolt." }, "confirm_dialog": { "confirm": "Megerősítés", diff --git a/locales/it/common.json b/locales/it/common.json index 48421a212..51fce9613 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Rimuovi colore di sfondo" }, "send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta.", - "pop_out": "Apri la risposta in una finestra" + "pop_out": "Apri la risposta in una finestra", + "send_guard_failed": "Non inviato - un plugin che controlla i messaggi prima dell'invio non è riuscito o non ha risposto." }, "confirm_dialog": { "confirm": "Conferma", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0450880d7..b7054e488 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -813,7 +813,8 @@ "remove_background_color": "背景色を削除" }, "send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。", - "pop_out": "返信を別ウィンドウで開く" + "pop_out": "返信を別ウィンドウで開く", + "send_guard_failed": "送信されませんでした - 送信前にメッセージを確認するプラグインが失敗したか、応答しませんでした。" }, "confirm_dialog": { "confirm": "確認", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9dc945c01..87b4831c8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -813,7 +813,8 @@ "remove_background_color": "배경색 제거" }, "send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다.", - "pop_out": "답장을 별도 창에서 열기" + "pop_out": "답장을 별도 창에서 열기", + "send_guard_failed": "보내지 않았습니다 - 보내기 전에 메시지를 확인하는 플러그인이 실패했거나 응답하지 않았습니다." }, "confirm_dialog": { "confirm": "확인", diff --git a/locales/lv/common.json b/locales/lv/common.json index 87288d927..3c9e7c335 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Noņemt fona krāsu" }, "send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts.", - "pop_out": "Atvērt atbildi atsevišķā logā" + "pop_out": "Atvērt atbildi atsevišķā logā", + "send_guard_failed": "Nav nosūtīts - spraudnis, kas pārbauda ziņojumus pirms sūtīšanas, neizdevās vai neatbildēja." }, "confirm_dialog": { "confirm": "Apstiprināt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 563431c54..f5494b653 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Дэвсгэр өнгийг арилгах" }, "send_filing_warning": "Илгээсэн - гэхдээ илгээсний дараах цэвэрлэгээ амжилтгүй болсон тул хуучирсан ноорог үлдэж болзошгүй.", - "pop_out": "Хариуг тусдаа цонхонд нээх" + "pop_out": "Хариуг тусдаа цонхонд нээх", + "send_guard_failed": "Илгээгдсэнгүй - илгээхийн өмнө мессежийг шалгадаг залгаас алдаа гаргасан эсвэл хариу өгсөнгүй." }, "confirm_dialog": { "confirm": "Баталгаажуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index c23c8dbbd..c5b3f89d3 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Fjern bakgrunnsfarge" }, "send_filing_warning": "Meldingen ble sendt, men oppryddingen etterpå mislyktes. Et gammelt utkast kan bli liggende.", - "pop_out": "Åpne svaret i et eget vindu" + "pop_out": "Åpne svaret i et eget vindu", + "send_guard_failed": "Ikke sendt - et tillegg som sjekker meldinger før sending, feilet eller svarte ikke." }, "confirm_dialog": { "confirm": "Bekreft", diff --git a/locales/nl/common.json b/locales/nl/common.json index d801d2f78..26a85faf7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Achtergrondkleur verwijderen" }, "send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan.", - "pop_out": "Antwoord in een apart venster openen" + "pop_out": "Antwoord in een apart venster openen", + "send_guard_failed": "Niet verzonden - een plug-in die berichten controleert voor het verzenden is mislukt of reageerde niet." }, "confirm_dialog": { "confirm": "Bevestigen", diff --git a/locales/pl/common.json b/locales/pl/common.json index f38b2509d..bfb305cae 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Usuń kolor tła" }, "send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza.", - "pop_out": "Otwórz odpowiedź w oknie" + "pop_out": "Otwórz odpowiedź w oknie", + "send_guard_failed": "Nie wysłano - wtyczka sprawdzająca wiadomości przed wysłaniem zawiodła lub nie odpowiedziała." }, "confirm_dialog": { "confirm": "Potwierdź", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8d40cd3b1..5297e68f0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Remover cor de fundo" }, "send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer.", - "pop_out": "Abrir a resposta numa janela" + "pop_out": "Abrir a resposta numa janela", + "send_guard_failed": "Não enviado - um plugin que verifica as mensagens antes do envio falhou ou não respondeu." }, "confirm_dialog": { "confirm": "Confirmar", diff --git a/locales/ro/common.json b/locales/ro/common.json index 9aed8133b..0f15254b7 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Elimină culoarea fundalului" }, "send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche.", - "pop_out": "Deschide răspunsul într-o fereastră" + "pop_out": "Deschide răspunsul într-o fereastră", + "send_guard_failed": "Nu s-a trimis - un plugin care verifică mesajele înainte de trimitere a eșuat sau nu a răspuns." }, "confirm_dialog": { "confirm": "Confirmare", diff --git a/locales/ru/common.json b/locales/ru/common.json index 985279c82..619d030c6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Убрать цвет фона" }, "send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик.", - "pop_out": "Открыть ответ в отдельном окне" + "pop_out": "Открыть ответ в отдельном окне", + "send_guard_failed": "Не отправлено: плагин, проверяющий письма перед отправкой, завершился с ошибкой или не ответил." }, "confirm_dialog": { "confirm": "Подтвердить", diff --git a/locales/sk/common.json b/locales/sk/common.json index 3e87836d8..1dccdcf4a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Odstrániť farbu pozadia" }, "send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept.", - "pop_out": "Otvoriť odpoveď v okne" + "pop_out": "Otvoriť odpoveď v okne", + "send_guard_failed": "Neodoslané - doplnok, ktorý kontroluje správy pred odoslaním, zlyhal alebo neodpovedal." }, "confirm_dialog": { "confirm": "Potvrdiť", diff --git a/locales/tr/common.json b/locales/tr/common.json index f58996755..6a24a99aa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Arka plan rengini kaldır" }, "send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir.", - "pop_out": "Yanıtı ayrı pencerede aç" + "pop_out": "Yanıtı ayrı pencerede aç", + "send_guard_failed": "Gönderilmedi - iletileri göndermeden önce denetleyen bir eklenti başarısız oldu veya yanıt vermedi." }, "confirm_dialog": { "confirm": "Onayla", diff --git a/locales/uk/common.json b/locales/uk/common.json index f6d50a2e7..2b7180baa 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -813,7 +813,8 @@ "remove_background_color": "Прибрати колір фону" }, "send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка.", - "pop_out": "Відкрити відповідь в окремому вікні" + "pop_out": "Відкрити відповідь в окремому вікні", + "send_guard_failed": "Не надіслано: плагін, що перевіряє листи перед надсиланням, завершився з помилкою або не відповів." }, "confirm_dialog": { "confirm": "Підтвердити", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4399d8b3a..0c80356c6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -813,7 +813,8 @@ "remove_background_color": "移除背景顏色" }, "send_filing_warning": "郵件已傳送,但寄送後清理失敗,可能仍保留舊草稿。", - "pop_out": "在獨立視窗中開啟回覆" + "pop_out": "在獨立視窗中開啟回覆", + "send_guard_failed": "未傳送 - 傳送前檢查郵件的外掛程式發生錯誤或沒有回應。" }, "confirm_dialog": { "confirm": "確認", diff --git a/locales/zh/common.json b/locales/zh/common.json index d12bc01c5..c9fb040e5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -813,7 +813,8 @@ "remove_background_color": "移除背景颜色" }, "send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。", - "pop_out": "在单独窗口中打开回复" + "pop_out": "在单独窗口中打开回复", + "send_guard_failed": "未发送 - 发送前检查邮件的插件出错或没有响应。" }, "confirm_dialog": { "confirm": "确认", From ff1d1f399d28c81ac9c0f84575a208b857a72267 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:15:45 +0200 Subject: [PATCH 010/147] fix: limit plugin dialogs to one at a time, name the plugin in each and offer to turn it off --- components/plugins/plugin-dialog-host.tsx | 207 ++++++++++------------ lib/__tests__/plugin-host-dialog.test.ts | 70 ++++++++ lib/plugin-sandbox/host-dialog.ts | 40 +++++ locales/ar/common.json | 10 ++ locales/ca/common.json | 10 ++ locales/cs/common.json | 10 ++ locales/da/common.json | 10 ++ locales/de/common.json | 10 ++ locales/en/common.json | 10 ++ locales/es/common.json | 10 ++ locales/fa/common.json | 10 ++ locales/fr/common.json | 10 ++ locales/he/common.json | 10 ++ locales/hu/common.json | 10 ++ locales/it/common.json | 10 ++ locales/ja/common.json | 10 ++ locales/ko/common.json | 10 ++ locales/lv/common.json | 10 ++ locales/mn/common.json | 10 ++ locales/nb/common.json | 10 ++ locales/nl/common.json | 10 ++ locales/pl/common.json | 10 ++ locales/pt/common.json | 10 ++ locales/ro/common.json | 10 ++ locales/ru/common.json | 10 ++ locales/sk/common.json | 10 ++ locales/tr/common.json | 10 ++ locales/uk/common.json | 10 ++ locales/zh-TW/common.json | 10 ++ locales/zh/common.json | 10 ++ 30 files changed, 477 insertions(+), 110 deletions(-) create mode 100644 lib/__tests__/plugin-host-dialog.test.ts diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index 1a9d3a650..fcdee26d2 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -6,8 +6,12 @@ // (optionally masked) fields so plugins never fall back to `window.prompt`, // which the sandbox blocks. -import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; -import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; +import React, { useEffect, useId, useMemo, useState, useSyncExternalStore } from 'react'; +import { useTranslations } from 'next-intl'; +import { cancelForPlugin, head, resolveDialog, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; +import { usePluginStore } from '@/stores/plugin-store'; +import { Button } from '@/components/ui/button'; +import { Puzzle, X } from '@/components/icons'; import { PluginIframeSlot } from './plugin-iframe-slot'; import type { SlotName } from '@/lib/plugin-types'; @@ -26,9 +30,14 @@ function renderMessage(message?: string): React.ReactNode { export function PluginDialogHost(): React.JSX.Element | null { const current = useSyncExternalStore(subscribe, head, () => null); + const t = useTranslations('plugins.dialog'); + const titleId = useId(); + const pluginName = usePluginStore((s) => s.plugins.find((p) => p.id === current?.pluginId)?.name); + const disablePlugin = usePluginStore((s) => s.disablePlugin); const isPrompt = current?.kind === 'prompt'; const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]); + const hasPassword = fields.some((f) => f.type === 'password'); // Field values for a prompt, re-initialised whenever a new dialog surfaces. const [values, setValues] = useState>({}); @@ -39,10 +48,24 @@ export function PluginDialogHost(): React.JSX.Element | null { setValues(init); }, [current]); + // The custom slot's onResult answers its own dialog only; a stray second + // call after it closed must not resolve the next one in the queue. + const customProps = useMemo(() => { + if (current?.kind !== 'custom') return undefined; + const id = current.id; + return { ...(current.extraProps ?? {}), onResult: (value: unknown) => resolveDialog(id, value) }; + }, [current]); + const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0); const cancel = () => resolveHead(current?.kind === 'prompt' || current?.kind === 'custom' ? null : false); const submitPrompt = () => { if (canSubmit) resolveHead(values); }; + const turnOff = () => { + if (!current) return; + const id = current.pluginId; + cancelForPlugin(id); + disablePlugin(id); + }; useEffect(() => { if (!current) return; @@ -66,135 +89,99 @@ export function PluginDialogHost(): React.JSX.Element | null { if (!current) return null; - const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : current.kind === 'prompt' ? 'Submit' : 'Confirm'); - const cancelLabel = current.cancelLabel ?? 'Cancel'; - - const btnBase: React.CSSProperties = { padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer' }; - const secondaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit' }; - const primaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid transparent', background: 'var(--color-primary, #3b82f6)', color: 'var(--color-primary-foreground, #fff)' }; + const name = pluginName ?? current.pluginId; + const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? t('ok') : current.kind === 'prompt' ? t('submit') : t('confirm')); + const cancelLabel = current.cancelLabel ?? t('cancel'); return (
{ if (e.target === e.currentTarget) cancel(); }} >
-
-

- {current.title} -

+ {/* Who is asking. Plugins choose the title and message, so the host + names the plugin itself, in a place the plugin can't change. */} +
+ + {t('from_plugin', { name })} {current.kind === 'custom' && ( - )}
- {current.message && ( -

- {renderMessage(current.message)} -

- )} - {current.kind === 'custom' ? ( - resolveHead(value) }} - /> - ) : isPrompt ? ( -
{ e.preventDefault(); submitPrompt(); }}> -
- {fields.map((f, i) => ( - - ))} -
-
- - + +
+
+ ) : ( +
+ {current.kind === 'confirm' && ( + + )} + +
- - ) : ( -
- {current.kind === 'confirm' && ( - - )} - -
- )} -
- From plugin: {current.pluginId} + )} +
+ +
+
diff --git a/lib/__tests__/plugin-host-dialog.test.ts b/lib/__tests__/plugin-host-dialog.test.ts new file mode 100644 index 000000000..f8047eb7a --- /dev/null +++ b/lib/__tests__/plugin-host-dialog.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + awaitDialog, + awaitCustomDialog, + cancelForPlugin, + head, + resolveDialog, + resolveHead, + resetDialogLimits, + MAX_DIALOGS_PER_MINUTE, +} from '../plugin-sandbox/host-dialog'; + +function confirm(pluginId: string) { + return awaitDialog({ pluginId, kind: 'confirm', title: 't', message: 'm' }); +} + +beforeEach(() => { + while (head()) resolveHead(false); + resetDialogLimits(); +}); + +describe('plugin dialog queue', () => { + it('lets a plugin have one dialog open at a time', async () => { + // A plugin opening dialogs in a loop used to cover the whole app; the + // user could not reach Settings to turn it off. + const first = confirm('looper'); + await expect(confirm('looper')).rejects.toThrow(/already has a dialog open/); + resolveHead(true); + expect(await first).toBe(true); + }); + + it('does not let one plugin block dialogs of another', async () => { + const a = confirm('a'); + const b = confirm('b'); + resolveHead(true); + resolveHead(false); + expect(await a).toBe(true); + expect(await b).toBe(false); + }); + + it('limits how many dialogs a plugin opens per minute', async () => { + for (let i = 0; i < MAX_DIALOGS_PER_MINUTE; i++) { + const p = confirm('chatty'); + resolveHead(true); + await p; + } + await expect(confirm('chatty')).rejects.toThrow(/too many dialogs/); + }); + + it("resolves a custom dialog's own entry, not the next one in the queue", async () => { + const custom = awaitCustomDialog({ pluginId: 'a', title: 't', message: '', slot: 'plugin-dialog' }); + const id = head()!.id; + const other = confirm('b'); + + resolveDialog(id, 'picked'); + resolveDialog(id, 'again'); // a second onResult call after the dialog closed + + expect(await custom).toBe('picked'); + expect(head()?.pluginId).toBe('b'); + resolveHead(false); + expect(await other).toBe(false); + }); + + it('cancels a plugin\'s dialogs when it is turned off', async () => { + const p = confirm('gone'); + cancelForPlugin('gone'); + expect(await p).toBe(false); + expect(head()).toBeNull(); + }); +}); diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts index 68159cefa..7289dc496 100644 --- a/lib/plugin-sandbox/host-dialog.ts +++ b/lib/plugin-sandbox/host-dialog.ts @@ -67,7 +67,28 @@ function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } +/** How many dialogs one plugin may open per minute. */ +export const MAX_DIALOGS_PER_MINUTE = 20; +const recentByPlugin = new Map(); + +export class DialogLimitError extends Error {} + +/** + * Queue a dialog. A plugin gets one dialog at a time and a limited number + * per minute: with neither limit, a plugin that opens dialogs in a loop + * covers the whole app and the user can't reach Settings to turn it off. + */ export function enqueueDialog(req: Omit): { id: string } { + if (queue.some((d) => d.pluginId === req.pluginId)) { + throw new DialogLimitError('This plugin already has a dialog open'); + } + const now = Date.now(); + const recent = (recentByPlugin.get(req.pluginId) ?? []).filter((t) => now - t < 60_000); + if (recent.length >= MAX_DIALOGS_PER_MINUTE) { + throw new DialogLimitError('This plugin opened too many dialogs; try again in a minute'); + } + recent.push(now); + recentByPlugin.set(req.pluginId, recent); const entry: DialogRequest = { ...req, id: uid() }; queue.push(entry); notify(); @@ -85,6 +106,23 @@ export function resolveHead(result: unknown): void { notify(); } +/** + * Resolve one specific dialog. A custom dialog's onResult uses this, so a + * second call after it closed can't answer whatever dialog came next. + */ +export function resolveDialog(id: string, result: unknown): void { + const idx = queue.findIndex((d) => d.id === id); + if (idx < 0) return; + const [entry] = queue.splice(idx, 1); + try { entry.resolve(result); } catch { /* ignore */ } + notify(); +} + +/** Forget rate-limit history (tests). */ +export function resetDialogLimits(): void { + recentByPlugin.clear(); +} + /** The "cancelled" result for a given dialog kind (null for prompt/custom, else false). */ function cancelledResult(kind: DialogKind): unknown { return kind === 'prompt' || kind === 'custom' ? null : false; @@ -118,6 +156,8 @@ export function awaitDialog(req: Omit): Promise enqueueDialog({ ...req, resolve: (r) => resolve(r === true) }); }); } +// (enqueueDialog throws synchronously inside the executor, which rejects +// the promise, so the plugin's await sees the limit error.) /** * Prompt variant of `awaitDialog`: resolves to the collected name→value map on diff --git a/locales/ar/common.json b/locales/ar/common.json index 78a772c82..d63070241 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4241,6 +4241,16 @@ "title": "معرفة وقت بدء التطبيق وتوقفه", "body": "تلقّي إشعار عند بدء التطبيق أو انقطاع اتصاله أو تسجيل خروجك." } + }, + "dialog": { + "from_plugin": "من الإضافة {name}", + "ok": "حسنًا", + "submit": "إرسال", + "confirm": "تأكيد", + "cancel": "إلغاء", + "close": "إغلاق", + "turn_off": "إيقاف {name}", + "password_note": "هذا النموذج من الإضافة {name} وليس من بريد الويب. لا تدخل كلمة مرور إلا إذا كنت تثق بهذه الإضافة." } }, "global_search": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 43c1a1c50..9b8c72810 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4241,6 +4241,16 @@ "title": "Saber quan l'aplicació s'inicia i s'atura", "body": "Rebre un avís quan l'aplicació s'inicia, es queda sense connexió o tanqueu la sessió." } + }, + "dialog": { + "from_plugin": "Del connector {name}", + "ok": "D'acord", + "submit": "Envia", + "confirm": "Confirma", + "cancel": "Cancel·la", + "close": "Tanca", + "turn_off": "Desactiva {name}", + "password_note": "Aquest formulari ve del connector {name}, no del correu web. Introduïu una contrasenya només si confieu en aquest connector." } }, "global_search": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 39adc31b6..925b79239 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4241,6 +4241,16 @@ "title": "Vědět, kdy se aplikace spouští a ukončuje", "body": "Dostává zprávu, když se aplikace spustí, přejde do režimu offline nebo když se odhlásíte." } + }, + "dialog": { + "from_plugin": "Od pluginu {name}", + "ok": "OK", + "submit": "Odeslat", + "confirm": "Potvrdit", + "cancel": "Zrušit", + "close": "Zavřít", + "turn_off": "Vypnout {name}", + "password_note": "Tento formulář pochází z pluginu {name}, ne z webmailu. Heslo zadejte jen tehdy, když tomuto pluginu důvěřujete." } }, "global_search": { diff --git a/locales/da/common.json b/locales/da/common.json index da431497a..77310b3b1 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4241,6 +4241,16 @@ "title": "Vide, når appen starter og stopper", "body": "Få besked, når appen starter, går offline, eller du logger ud." } + }, + "dialog": { + "from_plugin": "Fra pluginet {name}", + "ok": "OK", + "submit": "Send", + "confirm": "Bekræft", + "cancel": "Annuller", + "close": "Luk", + "turn_off": "Slå {name} fra", + "password_note": "Denne formular kommer fra pluginet {name}, ikke fra webmailen. Indtast kun en adgangskode, hvis du stoler på dette plugin." } }, "global_search": { diff --git a/locales/de/common.json b/locales/de/common.json index 1ea34da40..7f09db870 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4241,6 +4241,16 @@ "title": "Erfahren, wann die App startet und beendet wird", "body": "Benachrichtigt werden, wenn die App startet, offline geht oder Sie sich abmelden." } + }, + "dialog": { + "from_plugin": "Vom Plugin {name}", + "ok": "OK", + "submit": "Senden", + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "close": "Schließen", + "turn_off": "{name} ausschalten", + "password_note": "Dieses Formular stammt vom Plugin {name}, nicht vom Webmail. Geben Sie ein Passwort nur ein, wenn Sie diesem Plugin vertrauen." } }, "global_search": { diff --git a/locales/en/common.json b/locales/en/common.json index 7f191767f..5ba9ae751 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4241,6 +4241,16 @@ "title": "Know when the app starts and stops", "body": "Be told when the app starts, goes offline or you sign out." } + }, + "dialog": { + "from_plugin": "From the plugin {name}", + "ok": "OK", + "submit": "Submit", + "confirm": "Confirm", + "cancel": "Cancel", + "close": "Close", + "turn_off": "Turn off {name}", + "password_note": "This form comes from the plugin {name}, not from the webmail. Only enter a password if you trust this plugin." } }, "global_search": { diff --git a/locales/es/common.json b/locales/es/common.json index a3e2fb538..3d60e509d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4241,6 +4241,16 @@ "title": "Saber cuándo se inicia y se cierra la aplicación", "body": "Recibir un aviso cuando la aplicación se inicia, se queda sin conexión o cierras sesión." } + }, + "dialog": { + "from_plugin": "Del complemento {name}", + "ok": "Aceptar", + "submit": "Enviar", + "confirm": "Confirmar", + "cancel": "Cancelar", + "close": "Cerrar", + "turn_off": "Desactivar {name}", + "password_note": "Este formulario viene del complemento {name}, no del correo web. Introduce una contraseña solo si confías en este complemento." } }, "global_search": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 243b2e1f0..69d5e7445 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4241,6 +4241,16 @@ "title": "باخبر شدن از شروع و توقف برنامه", "body": "باخبر شدن وقتی برنامه شروع می‌شود، آفلاین می‌شود یا از حساب خارج می‌شوید." } + }, + "dialog": { + "from_plugin": "از افزونه {name}", + "ok": "تأیید", + "submit": "ارسال", + "confirm": "تأیید", + "cancel": "لغو", + "close": "بستن", + "turn_off": "خاموش کردن {name}", + "password_note": "این فرم از افزونه {name} است، نه از وب‌میل. فقط اگر به این افزونه اعتماد دارید گذرواژه وارد کنید." } }, "global_search": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 77f4388f3..8bfe55d18 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4241,6 +4241,16 @@ "title": "Savoir quand l'application démarre et s'arrête", "body": "Être averti lorsque l'application démarre, passe hors ligne ou que vous vous déconnectez." } + }, + "dialog": { + "from_plugin": "Du plugin {name}", + "ok": "OK", + "submit": "Envoyer", + "confirm": "Confirmer", + "cancel": "Annuler", + "close": "Fermer", + "turn_off": "Désactiver {name}", + "password_note": "Ce formulaire provient du plugin {name}, pas du webmail. Ne saisissez un mot de passe que si vous faites confiance à ce plugin." } }, "global_search": { diff --git a/locales/he/common.json b/locales/he/common.json index 511925fe4..7682a11c1 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4242,6 +4242,16 @@ "title": "לדעת מתי האפליקציה מופעלת ונעצרת", "body": "לקבל עדכון כשהאפליקציה מופעלת, עוברת למצב לא מקוון או כשאתם מתנתקים." } + }, + "dialog": { + "from_plugin": "מהתוסף {name}", + "ok": "אישור", + "submit": "שליחה", + "confirm": "אישור", + "cancel": "ביטול", + "close": "סגירה", + "turn_off": "כיבוי {name}", + "password_note": "הטופס הזה מגיע מהתוסף {name}, לא מהדואר האינטרנטי. הזינו סיסמה רק אם אתם סומכים על התוסף הזה." } }, "global_search": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 46d7388b9..285b6f2b9 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4241,6 +4241,16 @@ "title": "Tudomást szerez az alkalmazás indulásáról és leállásáról", "body": "Értesül, amikor az alkalmazás elindul, offline módba vált, vagy kijelentkezel." } + }, + "dialog": { + "from_plugin": "A(z) {name} bővítménytől", + "ok": "OK", + "submit": "Küldés", + "confirm": "Megerősítés", + "cancel": "Mégse", + "close": "Bezárás", + "turn_off": "{name} kikapcsolása", + "password_note": "Ez az űrlap a(z) {name} bővítménytől származik, nem a webmailtől. Csak akkor adjon meg jelszót, ha megbízik ebben a bővítményben." } }, "global_search": { diff --git a/locales/it/common.json b/locales/it/common.json index 51fce9613..5e6c81183 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4241,6 +4241,16 @@ "title": "Sapere quando l'app si avvia e si chiude", "body": "Ricevere un avviso quando l'app si avvia, va offline o ti disconnetti." } + }, + "dialog": { + "from_plugin": "Dal plugin {name}", + "ok": "OK", + "submit": "Invia", + "confirm": "Conferma", + "cancel": "Annulla", + "close": "Chiudi", + "turn_off": "Disattiva {name}", + "password_note": "Questo modulo proviene dal plugin {name}, non dalla webmail. Inserisci una password solo se ti fidi di questo plugin." } }, "global_search": { diff --git a/locales/ja/common.json b/locales/ja/common.json index b7054e488..c325571e3 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4241,6 +4241,16 @@ "title": "アプリの起動と終了を知る", "body": "アプリが起動したとき、オフラインになったとき、サインアウトしたときに通知を受けます。" } + }, + "dialog": { + "from_plugin": "プラグイン {name} から", + "ok": "OK", + "submit": "送信", + "confirm": "確認", + "cancel": "キャンセル", + "close": "閉じる", + "turn_off": "{name} をオフにする", + "password_note": "このフォームはウェブメールではなく、プラグイン {name} が表示しています。このプラグインを信頼できる場合にのみパスワードを入力してください。" } }, "global_search": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 87b4831c8..69c9ef73b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4241,6 +4241,16 @@ "title": "앱 시작과 종료 알림 받기", "body": "앱이 시작되거나, 오프라인이 되거나, 로그아웃할 때 알림을 받아요." } + }, + "dialog": { + "from_plugin": "플러그인 {name}에서", + "ok": "확인", + "submit": "제출", + "confirm": "확인", + "cancel": "취소", + "close": "닫기", + "turn_off": "{name} 끄기", + "password_note": "이 양식은 웹메일이 아니라 플러그인 {name}에서 표시한 것입니다. 이 플러그인을 신뢰할 때만 비밀번호를 입력하세요." } }, "global_search": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 3c9e7c335..ed70a2ad2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4241,6 +4241,16 @@ "title": "Zināt, kad lietotne startē un apstājas", "body": "Saņemt ziņu, kad lietotne startē, pāriet bezsaistē vai jūs izrakstāties." } + }, + "dialog": { + "from_plugin": "No spraudņa {name}", + "ok": "Labi", + "submit": "Nosūtīt", + "confirm": "Apstiprināt", + "cancel": "Atcelt", + "close": "Aizvērt", + "turn_off": "Izslēgt {name}", + "password_note": "Šī veidlapa nāk no spraudņa {name}, nevis no tīmekļa pasta. Ievadiet paroli tikai tad, ja uzticaties šim spraudnim." } }, "global_search": { diff --git a/locales/mn/common.json b/locales/mn/common.json index f5494b653..9ba13c1cd 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4241,6 +4241,16 @@ "title": "Апп эхлэх, зогсохыг мэдэх", "body": "Апп эхлэх, офлайн болох эсвэл таныг гарах үед мэдэгдэл авах." } + }, + "dialog": { + "from_plugin": "{name} залгаасаас", + "ok": "OK", + "submit": "Илгээх", + "confirm": "Баталгаажуулах", + "cancel": "Цуцлах", + "close": "Хаах", + "turn_off": "{name}-г унтраах", + "password_note": "Энэ маягт вэб шуудангаас биш, {name} залгаасаас ирсэн. Энэ залгаасанд итгэдэг бол л нууц үгээ оруулна уу." } }, "global_search": { diff --git a/locales/nb/common.json b/locales/nb/common.json index c5b3f89d3..0f2eb0706 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4241,6 +4241,16 @@ "title": "Vite når appen starter og stopper", "body": "Få beskjed når appen starter, går frakoblet eller du logger ut." } + }, + "dialog": { + "from_plugin": "Fra tillegget {name}", + "ok": "OK", + "submit": "Send", + "confirm": "Bekreft", + "cancel": "Avbryt", + "close": "Lukk", + "turn_off": "Slå av {name}", + "password_note": "Dette skjemaet kommer fra tillegget {name}, ikke fra webmailen. Skriv bare inn et passord hvis du stoler på dette tillegget." } }, "global_search": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 26a85faf7..40200e00b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4241,6 +4241,16 @@ "title": "Weten wanneer de app start en stopt", "body": "Een melding krijgen wanneer de app start, offline gaat of je je afmeldt." } + }, + "dialog": { + "from_plugin": "Van de plug-in {name}", + "ok": "OK", + "submit": "Versturen", + "confirm": "Bevestigen", + "cancel": "Annuleren", + "close": "Sluiten", + "turn_off": "{name} uitzetten", + "password_note": "Dit formulier komt van de plug-in {name}, niet van de webmail. Vul alleen een wachtwoord in als je deze plug-in vertrouwt." } }, "global_search": { diff --git a/locales/pl/common.json b/locales/pl/common.json index bfb305cae..f280e9026 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4241,6 +4241,16 @@ "title": "Informacja o uruchomieniu i zamknięciu aplikacji", "body": "Otrzymuje informację, gdy aplikacja się uruchamia, przechodzi w tryb offline lub gdy się wylogowujesz." } + }, + "dialog": { + "from_plugin": "Od wtyczki {name}", + "ok": "OK", + "submit": "Wyślij", + "confirm": "Potwierdź", + "cancel": "Anuluj", + "close": "Zamknij", + "turn_off": "Wyłącz {name}", + "password_note": "Ten formularz pochodzi z wtyczki {name}, a nie z poczty. Wpisz hasło tylko wtedy, gdy ufasz tej wtyczce." } }, "global_search": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 5297e68f0..398581f46 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4241,6 +4241,16 @@ "title": "Saber quando o aplicativo inicia e para", "body": "Ser avisado quando o aplicativo inicia, fica offline ou você sai." } + }, + "dialog": { + "from_plugin": "Do plugin {name}", + "ok": "OK", + "submit": "Enviar", + "confirm": "Confirmar", + "cancel": "Cancelar", + "close": "Fechar", + "turn_off": "Desativar {name}", + "password_note": "Este formulário vem do plugin {name}, não do webmail. Só introduza uma palavra-passe se confiar neste plugin." } }, "global_search": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 0f15254b7..0f848a590 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4241,6 +4241,16 @@ "title": "Aflarea momentului pornirii și opririi aplicației", "body": "Este anunțat când aplicația pornește, trece offline sau vă deconectați." } + }, + "dialog": { + "from_plugin": "De la pluginul {name}", + "ok": "OK", + "submit": "Trimite", + "confirm": "Confirmă", + "cancel": "Anulează", + "close": "Închide", + "turn_off": "Dezactivează {name}", + "password_note": "Acest formular vine de la pluginul {name}, nu de la webmail. Introduceți o parolă doar dacă aveți încredere în acest plugin." } }, "global_search": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 619d030c6..8dd5a1787 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4241,6 +4241,16 @@ "title": "Узнавать о запуске и остановке приложения", "body": "Получать уведомления, когда приложение запускается, теряет связь или вы выходите." } + }, + "dialog": { + "from_plugin": "От плагина {name}", + "ok": "ОК", + "submit": "Отправить", + "confirm": "Подтвердить", + "cancel": "Отмена", + "close": "Закрыть", + "turn_off": "Отключить {name}", + "password_note": "Эта форма от плагина {name}, а не от веб-почты. Вводите пароль, только если доверяете этому плагину." } }, "global_search": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 1dccdcf4a..b7c7f4ecf 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4241,6 +4241,16 @@ "title": "Vedieť, kedy sa aplikácia spúšťa a ukončuje", "body": "Dostáva správu, keď sa aplikácia spustí, prejde do režimu offline alebo keď sa odhlásite." } + }, + "dialog": { + "from_plugin": "Od doplnku {name}", + "ok": "OK", + "submit": "Odoslať", + "confirm": "Potvrdiť", + "cancel": "Zrušiť", + "close": "Zavrieť", + "turn_off": "Vypnúť {name}", + "password_note": "Tento formulár pochádza z doplnku {name}, nie z webmailu. Heslo zadajte, len ak tomuto doplnku dôverujete." } }, "global_search": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 6a24a99aa..c92a95395 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4241,6 +4241,16 @@ "title": "Uygulamanın başladığını ve durduğunu bilme", "body": "Uygulama başladığında, çevrimdışı olduğunda veya çıkış yaptığınızda haber alma." } + }, + "dialog": { + "from_plugin": "{name} eklentisinden", + "ok": "Tamam", + "submit": "Gönder", + "confirm": "Onayla", + "cancel": "İptal", + "close": "Kapat", + "turn_off": "{name} eklentisini kapat", + "password_note": "Bu form web postasından değil, {name} eklentisinden geliyor. Yalnızca bu eklentiye güveniyorsanız parola girin." } }, "global_search": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 2b7180baa..a8ce891cb 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4241,6 +4241,16 @@ "title": "Знати, коли програма запускається й зупиняється", "body": "Отримувати сповіщення, коли програма запускається, втрачає зв'язок або ви виходите." } + }, + "dialog": { + "from_plugin": "Від плагіна {name}", + "ok": "OK", + "submit": "Надіслати", + "confirm": "Підтвердити", + "cancel": "Скасувати", + "close": "Закрити", + "turn_off": "Вимкнути {name}", + "password_note": "Ця форма від плагіна {name}, а не від вебпошти. Вводьте пароль, лише якщо довіряєте цьому плагіну." } }, "global_search": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0c80356c6..c62928181 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4241,6 +4241,16 @@ "title": "得知應用程式何時啟動和停止", "body": "在應用程式啟動、離線或您登出時收到通知。" } + }, + "dialog": { + "from_plugin": "來自外掛程式 {name}", + "ok": "確定", + "submit": "送出", + "confirm": "確認", + "cancel": "取消", + "close": "關閉", + "turn_off": "關閉 {name}", + "password_note": "此表單來自外掛程式 {name},而不是網頁郵件。只有在您信任此外掛程式時才輸入密碼。" } }, "global_search": { diff --git a/locales/zh/common.json b/locales/zh/common.json index c9fb040e5..eb74276ae 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4241,6 +4241,16 @@ "title": "得知应用何时启动和停止", "body": "在应用启动、离线或您退出登录时收到通知。" } + }, + "dialog": { + "from_plugin": "来自插件 {name}", + "ok": "确定", + "submit": "提交", + "confirm": "确认", + "cancel": "取消", + "close": "关闭", + "turn_off": "关闭 {name}", + "password_note": "此表单来自插件 {name},而不是网页邮箱。只有在您信任此插件时才输入密码。" } }, "global_search": { From eadff464a30ed76a746a8659bbe52580ee4b417f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:19:10 +0200 Subject: [PATCH 011/147] fix: keep plugin shortcuts behind ui:keyboard and off the keys the app and browser use --- lib/__tests__/plugin-sandbox-loader.test.ts | 30 ++++++++- lib/__tests__/plugin-shortcuts.test.ts | 67 +++++++++++++++++++++ lib/plugin-sandbox/loader.ts | 15 +++-- lib/plugin-sandbox/shortcuts.ts | 41 ++++++++++++- 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/plugin-shortcuts.test.ts diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 307a5f2a5..1df200de3 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ saveCode: vi.fn(), downloadManagedBundle: vi.fn(), createBackgroundInstance: vi.fn(), + registerShortcuts: vi.fn((..._args: unknown[]) => () => {}), })); vi.mock('../plugin-storage', () => ({ @@ -27,7 +28,7 @@ vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn(), })); vi.mock('../plugin-sandbox/shortcuts', () => ({ - registerShortcuts: () => () => {}, + registerShortcuts: (...args: unknown[]) => mocks.registerShortcuts(...args), })); import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader'; @@ -177,3 +178,30 @@ describe('loadSandboxedPlugin bundle sourcing', () => { expect(lastStatus()).toMatchObject({ status: 'error', error: expect.stringMatching(/integrity mismatch/) }); }); }); + +describe('plugin shortcuts', () => { + const SHORTCUT = [{ id: 'new', keys: 'Alt+N', label: 'New' }]; + function withShortcuts() { + return { ...fakeBackground(), initPromise: Promise.resolve({ hooks: [], slots: [], shortcuts: SHORTCUT }) }; + } + + it('does not install shortcuts for a plugin without ui:keyboard', async () => { + mocks.getCode.mockResolvedValue(CODE); + mocks.createBackgroundInstance.mockImplementation(withShortcuts); + mocks.registerShortcuts.mockClear(); + + await loadSandboxedPlugin(plugin()); + + expect(mocks.registerShortcuts).not.toHaveBeenCalled(); + }); + + it('installs them with ui:keyboard', async () => { + mocks.getCode.mockResolvedValue(CODE); + mocks.createBackgroundInstance.mockImplementation(withShortcuts); + mocks.registerShortcuts.mockClear(); + + await loadSandboxedPlugin(plugin({ permissions: ['ui:keyboard'] })); + + expect(mocks.registerShortcuts).toHaveBeenCalledWith(expect.anything(), SHORTCUT); + }); +}); diff --git a/lib/__tests__/plugin-shortcuts.test.ts b/lib/__tests__/plugin-shortcuts.test.ts new file mode 100644 index 000000000..3344609cb --- /dev/null +++ b/lib/__tests__/plugin-shortcuts.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { registerShortcuts, isAllowedPluginCombo } from '../plugin-sandbox/shortcuts'; +import type { SandboxInstance } from '../plugin-sandbox/host-bridge'; + +function instance(pluginId: string) { + return { pluginId, invokeHook: vi.fn(async () => undefined) } as unknown as SandboxInstance & { invokeHook: ReturnType }; +} + +const disposers: Array<() => void> = []; +afterEach(() => { + while (disposers.length) disposers.pop()!(); +}); + +function press(key: string, mods: Partial> = {}) { + const ev = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...mods }); + const outer = vi.fn(); + document.body.addEventListener('keydown', outer); + document.body.dispatchEvent(ev); + document.body.removeEventListener('keydown', outer); + return { defaultPrevented: ev.defaultPrevented, reachedApp: outer.mock.calls.length > 0 }; +} + +describe('plugin keyboard shortcuts', () => { + it.each(['Escape', 'c', 'Shift+G', 'Ctrl+K', 'Ctrl+F', 'Ctrl+P', 'Cmd+W', 'Ctrl+Shift+T', 'Tab', 'Alt+Tab'])( + 'refuses %s, which belongs to the app or the browser', + (combo) => { + expect(isAllowedPluginCombo(combo)).toBe(false); + }, + ); + + it.each(['Alt+N', 'Alt+Shift+N', 'Ctrl+Shift+L', 'Ctrl+Alt+S', 'Cmd+Shift+Y'])('allows %s', (combo) => { + expect(isAllowedPluginCombo(combo)).toBe(true); + }); + + it('does not take Escape away from the app', async () => { + const plugin = instance('grabber'); + disposers.push(registerShortcuts(plugin, [{ id: 'esc', keys: 'Escape', label: 'x' }])); + + const result = press('Escape'); + + expect(plugin.invokeHook).not.toHaveBeenCalled(); + expect(result.defaultPrevented).toBe(false); + expect(result.reachedApp).toBe(true); + }); + + it('runs an allowed shortcut without hiding the key from the rest of the app', async () => { + const plugin = instance('notes'); + disposers.push(registerShortcuts(plugin, [{ id: 'new', keys: 'Alt+N', label: 'New note' }])); + + const result = press('n', { altKey: true }); + + expect(plugin.invokeHook).toHaveBeenCalledWith('shortcut:new', []); + expect(result.reachedApp).toBe(true); + }); + + it('keeps the first plugin that claimed a combo', async () => { + const first = instance('first'); + const second = instance('second'); + disposers.push(registerShortcuts(first, [{ id: 'a', keys: 'Alt+J', label: 'a' }])); + disposers.push(registerShortcuts(second, [{ id: 'b', keys: 'Alt+J', label: 'b' }])); + + press('j', { altKey: true }); + + expect(first.invokeHook).toHaveBeenCalled(); + expect(second.invokeHook).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index a01b0f6a0..679661ae4 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -21,7 +21,7 @@ import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; -import { mayOfferSlot, mayRegisterHook } from './permissions'; +import { mayOfferSlot, mayRegisterHook, pluginHasPermission } from './permissions'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -193,9 +193,16 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise unknown)); } - // Install plugin-declared keyboard shortcuts. - const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []); - hookDisposables.push({ dispose: shortcutDispose }); + // Install plugin-declared keyboard shortcuts. A shortcut sees every + // key press outside text fields, so it needs ui:keyboard like the hooks. + if ((info.shortcuts ?? []).length > 0) { + if (pluginHasPermission(plugin, 'ui:keyboard')) { + const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []); + hookDisposables.push({ dispose: shortcutDispose }); + } else { + console.warn(`[plugin-sandbox] Plugin "${plugin.id}" declares shortcuts without the ui:keyboard permission; not registered`); + } + } const slotOffers = info.slots.filter((offer) => { if (mayOfferSlot(plugin, offer.name)) return true; diff --git a/lib/plugin-sandbox/shortcuts.ts b/lib/plugin-sandbox/shortcuts.ts index 1b1408270..3e69ce5e7 100644 --- a/lib/plugin-sandbox/shortcuts.ts +++ b/lib/plugin-sandbox/shortcuts.ts @@ -49,6 +49,31 @@ function normaliseCombo(combo: string): NormalisedCombo | null { return { ctrl, shift, alt, meta, key }; } +// Keys a plugin may never bind: the app and the browser need them. +const RESERVED_KEYS = new Set(['escape', 'tab', 'enter', 'f1', 'f5', 'f11', 'f12', 'delete', 'backspace']); +// Ctrl/Cmd+Shift chords browsers use (reopen tab, new private window, +// devtools, …). +const BROWSER_SHIFT_CHORDS = new Set(['t', 'n', 'w', 'q', 'i', 'j', 'c', 'r', 'p', 'b', 'o', 'delete', 'tab', 'enter', 'k']); + +/** + * Whether a plugin may bind this combo. The app uses bare keys (j, k, c, /, + * Shift+G…) and Escape, and browsers own plain Ctrl/Cmd chords, so a plugin + * needs Alt, Ctrl/Cmd+Shift or Ctrl/Cmd+Alt. + */ +export function isAllowedPluginCombo(keys: string): boolean { + const combo = normaliseCombo(keys); + if (!combo) return false; + if (RESERVED_KEYS.has(combo.key)) return false; + const primary = combo.ctrl || combo.meta; + if (combo.alt) return true; + if (primary && combo.shift) return !BROWSER_SHIFT_CHORDS.has(combo.key); + return false; +} + +function comboId(combo: NormalisedCombo): string { + return `${combo.ctrl || combo.meta ? 'C' : ''}${combo.shift ? 'S' : ''}${combo.alt ? 'A' : ''}+${combo.key}`; +} + function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean { if (combo.ctrl !== (ev.ctrlKey || ev.metaKey ? ev.ctrlKey : false)) { // Treat Ctrl and Cmd as equivalent: a binding declaring Ctrl matches a @@ -70,8 +95,8 @@ function onKeyDown(ev: KeyboardEvent): void { const combo = normaliseCombo(binding.keys); if (!combo) continue; if (eventMatches(ev, combo)) { + // Not stopPropagation: the app's own listeners still see the key. ev.preventDefault(); - ev.stopPropagation(); void binding.invoke(); return; } @@ -90,8 +115,22 @@ export function registerShortcuts( ): () => void { ensureListener(); const keys: string[] = []; + const taken = new Set([...bindings.values()].map((b) => { + const c = normaliseCombo(b.keys); + return c ? comboId(c) : ''; + })); for (const sc of shortcuts) { const key = `${instance.pluginId}:${sc.id}`; + const combo = normaliseCombo(sc.keys); + if (!combo || !isAllowedPluginCombo(sc.keys)) { + console.warn(`[plugin-shortcuts] "${instance.pluginId}" may not bind ${sc.keys}; use Alt, Ctrl+Shift or Ctrl+Alt`); + continue; + } + if (taken.has(comboId(combo))) { + console.warn(`[plugin-shortcuts] ${sc.keys} is already used by another plugin; "${instance.pluginId}" does not get it`); + continue; + } + taken.add(comboId(combo)); bindings.set(key, { pluginId: instance.pluginId, shortcutId: sc.id, From 8f70951555b1876e6ea2f22ee3cf43b55cf4dec7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:21:45 +0200 Subject: [PATCH 012/147] fix: give plugins their default settings and apply setting changes without a reload --- components/settings/plugins-settings.tsx | 3 +- .../plugin-host-bridge-origin.test.ts | 22 ++++++++ lib/__tests__/plugin-settings.test.ts | 30 +++++++++++ lib/__tests__/plugin-store.test.ts | 21 ++++++++ lib/plugin-sandbox/host-bridge.ts | 28 ++++++++++- lib/plugin-sandbox/protocol.ts | 4 ++ lib/plugin-sandbox/runtime.tsx | 34 ++++++++++++- lib/plugin-settings.ts | 50 +++++++++++++++++++ stores/plugin-store.ts | 21 ++++++-- 9 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/plugin-settings.test.ts create mode 100644 lib/plugin-settings.ts diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 625a98e7c..07bc35aa5 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { PluginPermissionList } from '@/components/plugins/plugin-permission-list'; import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; +import { effectiveSettings } from '@/lib/plugin-settings'; const STATUS_COLORS: Record = { installed: 'bg-muted text-muted-foreground', @@ -306,7 +307,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo key={key} fieldKey={key} schema={schema} - value={plugin.settings[key] ?? schema.default} + value={effectiveSettings(plugin)[key]} onChange={(value) => onUpdateSettings({ [key]: value })} /> ))} diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index 415b7a016..3be214421 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -48,3 +48,25 @@ describe('host bridge origin pin for untrusted sandboxes', () => { expect(dispatchApiCall).not.toHaveBeenCalled(); }); }); + +describe('init payload settings', () => { + it('hands a new iframe the settings with defaults filled in', async () => { + const p = { + ...plugin(), + settings: { size: 64 }, + settingsSchema: { + size: { type: 'number' as const, label: 'Size', default: 160 }, + mode: { type: 'select' as const, label: 'Mode', default: 'auto', options: ['auto', 'manual'] }, + }, + }; + const inst = createBackgroundInstance({ plugin: p, code: '', locale: 'en', tier: 'untrusted' }); + instances.push(inst); + const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage'); + + post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' }); + await new Promise((r) => setTimeout(r, 0)); + + const init = sent.mock.calls.map(([msg]) => msg as { type: string; payload?: { manifest: { settings: unknown } } }).find((m) => m.type === 'init'); + expect(init?.payload?.manifest.settings).toEqual({ size: 64, mode: 'auto' }); + }); +}); diff --git a/lib/__tests__/plugin-settings.test.ts b/lib/__tests__/plugin-settings.test.ts new file mode 100644 index 000000000..347ad0bc9 --- /dev/null +++ b/lib/__tests__/plugin-settings.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { effectiveSettings } from '../plugin-settings'; +import type { SettingFieldSchema } from '../plugin-types'; + +const schema: Record = { + size: { type: 'number', label: 'Size', default: 160, min: 16, max: 512 }, + mode: { type: 'select', label: 'Mode', default: 'auto', options: ['auto', 'manual'] }, + on: { type: 'boolean', label: 'On', default: true }, +}; + +describe('effectiveSettings', () => { + it('gives the plugin its defaults before the user saved anything', () => { + // Plugins used to get {} and had to repeat every default in code. + expect(effectiveSettings({ settings: {}, settingsSchema: schema })).toEqual({ size: 160, mode: 'auto', on: true }); + }); + + it('keeps valid saved values', () => { + expect(effectiveSettings({ settings: { size: 64, mode: 'manual', on: false }, settingsSchema: schema })) + .toEqual({ size: 64, mode: 'manual', on: false }); + }); + + it('replaces values the schema no longer accepts', () => { + const result = effectiveSettings({ settings: { size: 9999, mode: 'removed-option', on: 'yes', old: 1 }, settingsSchema: schema }); + expect(result).toEqual({ size: 512, mode: 'auto', on: true }); + }); + + it('passes settings through for a plugin without a schema', () => { + expect(effectiveSettings({ settings: { anything: 1 } })).toEqual({ anything: 1 }); + }); +}); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 5d3b4a56f..4aac4b9da 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -40,6 +40,10 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/browser-navigation', () => ({ apiFetch: (...args: unknown[]) => mocks.apiFetch(...args), })); +const bridge = vi.hoisted(() => ({ setSettings: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + forEachInstance: (_id: string, fn: (i: { setSettings: typeof bridge.setSettings }) => void) => fn({ setSettings: bridge.setSettings }), +})); vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args), })); @@ -334,3 +338,20 @@ describe('plugins the user installs', () => { expect(usePluginStore.getState().plugins[0].error).toBe('approval-denied'); }); }); + +describe('updatePluginSettings', () => { + it('sends the new settings to the running plugin right away', () => { + // Settings used to reach a plugin only after a reload. + resetStore(); + bridge.setSettings.mockClear(); + usePluginStore.setState({ plugins: [mockPlugin({ + id: 'p', settings: {}, + settingsSchema: { size: { type: 'number', label: 'Size', default: 160, min: 16, max: 512 } }, + })] }); + + usePluginStore.getState().updatePluginSettings('p', { size: 9000 }); + + expect(usePluginStore.getState().plugins[0].settings.size).toBe(512); + expect(bridge.setSettings).toHaveBeenCalledWith({ size: 512 }); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 5b3f67ca6..f0453d2c5 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -12,6 +12,7 @@ import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol'; import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; +import { effectiveSettings } from '../plugin-settings'; import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, } from './protocol'; @@ -81,6 +82,21 @@ export interface InitDoneInfo { shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>; } +// ─── Live instances ────────────────────────────────────────── + +/** + * Every iframe currently running, background and slot alike, so a change + * that concerns a plugin (its settings, the app locale) reaches all of them. + */ +const liveInstances = new Set(); + +export function forEachInstance(pluginId: string | null, fn: (instance: SandboxInstance) => void): void { + for (const instance of liveInstances) { + if (pluginId !== null && instance.pluginId !== pluginId) continue; + try { fn(instance); } catch { /* one broken iframe must not stop the rest */ } + } +} + // ─── Sandbox instance ──────────────────────────────────────── export class SandboxInstance { @@ -165,6 +181,7 @@ export class SandboxInstance { this.listener = (ev) => this.onMessage(ev); window.addEventListener('message', this.listener); hostContainer.appendChild(this.iframe); + liveInstances.add(this); // Send init after the iframe runtime signals it's ready. this.readyPromise.then(() => { @@ -310,6 +327,12 @@ export class SandboxInstance { this.send({ type: 'theme-change', theme }); } + /** Push the plugin's current settings (see effectiveSettings). */ + setSettings(settings: Record): void { + if (this.destroyed) return; + this.send({ type: 'settings-change', settings }); + } + updateProps(props: Record): void { if (this.destroyed) return; // Stale references would leak if we kept growing the table without @@ -322,6 +345,7 @@ export class SandboxInstance { destroy(): void { if (this.destroyed) return; this.destroyed = true; + liveInstances.delete(this); window.removeEventListener('message', this.listener); this.iframe.remove(); for (const { reject } of this.pendingHookInvokes.values()) { @@ -348,7 +372,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan id: opts.plugin.id, version: opts.plugin.version, permissions: opts.plugin.permissions, - settings: { ...opts.plugin.settings }, + settings: effectiveSettings(opts.plugin), locales: opts.plugin.locales, httpOrigins: opts.plugin.httpOrigins, }, @@ -374,7 +398,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { id: opts.plugin.id, version: opts.plugin.version, permissions: opts.plugin.permissions, - settings: { ...opts.plugin.settings }, + settings: effectiveSettings(opts.plugin), locales: opts.plugin.locales, httpOrigins: opts.plugin.httpOrigins, }, diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index f24f7d5ac..21c1d504e 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -177,6 +177,9 @@ export interface LocaleChangeMsg { type: 'locale-change'; locale: string; } /** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */ export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; } +/** Host → sandbox: the user changed this plugin's settings. */ +export interface SettingsChangeMsg { type: 'settings-change'; settings: Record; } + export interface PropsUpdateMsg { type: 'props-update'; props: Record; } export interface SlotShouldShowMsg { @@ -193,6 +196,7 @@ export type HostToSandbox = | HookInvokeMsg | LocaleChangeMsg | ThemeChangeMsg + | SettingsChangeMsg | PropsUpdateMsg | SlotShouldShowMsg; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index cd377451e..03970b477 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -62,6 +62,19 @@ let bootDone = false; let readyPosted = false; const pendingApi = new Map void; reject: (err: Error) => void }>(); + +// The plugin's settings. One object for the iframe's lifetime, updated in +// place on 'settings-change', so code that kept a reference sees new values. +const settingsState: Record = {}; +const settingsListeners = new Set<(settings: Record) => void>(); + +function applySettings(next: Record): void { + for (const key of Object.keys(settingsState)) delete settingsState[key]; + Object.assign(settingsState, next); + for (const listener of settingsListeners) { + try { listener(settingsState); } catch (err) { console.error('[plugin] settings listener failed', err); } + } +} const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); const hookHandlers: Record unknown> = {}; @@ -215,11 +228,18 @@ interface PluginKeywordCounts { } function buildPluginApi(manifest: PluginManifest) { + applySettings({ ...manifest.settings }); return { plugin: { id: manifest.id, version: manifest.version, - settings: { ...manifest.settings }, + /** Current settings, defaults filled in. Updated in place when the user changes them. */ + settings: settingsState, + /** Called with the new settings whenever the user changes them. Returns an unsubscribe function. */ + onSettingsChange: (listener: (settings: Record) => void) => { + settingsListeners.add(listener); + return () => { settingsListeners.delete(listener); }; + }, }, crypto: { getPublicKeys: () => callApi('crypto.getPublicKeys', []), @@ -634,10 +654,18 @@ function bootSlot(payload: SlotInit): void { const SlotShell = () => { const wrapRef = React.useRef(null); const [props, setProps] = React.useState(currentProps); + const [, setSettingsVersion] = React.useState(0); React.useEffect(() => { propsListeners.add(setProps); return () => { propsListeners.delete(setProps); }; }, []); + // Re-render the slot when its settings change, so a component that reads + // api.plugin.settings while rendering shows the new values. + React.useEffect(() => { + const bump = () => setSettingsVersion((v) => v + 1); + settingsListeners.add(bump); + return () => { settingsListeners.delete(bump); }; + }, []); React.useEffect(() => { if (!wrapRef.current) return; let lastHeight = -1; @@ -759,6 +787,10 @@ function handleHostMessage(ev: MessageEvent): void { applyHostTheme(msg.theme); break; + case 'settings-change': + applySettings(msg.settings ?? {}); + break; + case 'props-update': slotPropsUpdater?.(msg.props ?? {}); break; diff --git a/lib/plugin-settings.ts b/lib/plugin-settings.ts new file mode 100644 index 000000000..0453b6046 --- /dev/null +++ b/lib/plugin-settings.ts @@ -0,0 +1,50 @@ +// What a plugin's settings actually are: the manifest defaults, overlaid +// with what the user saved, each value checked against the schema. Used by +// the settings page and by everything that hands settings to the plugin, +// so the two can't disagree. + +import type { InstalledPlugin, SettingFieldSchema } from './plugin-types'; + +function coerce(field: SettingFieldSchema, value: unknown): unknown { + switch (field.type) { + case 'boolean': + return typeof value === 'boolean' ? value : field.default; + case 'string': + return typeof value === 'string' ? value : field.default; + case 'select': + return typeof value === 'string' && Array.isArray(field.options) && field.options.includes(value) + ? value + : field.default; + case 'number': { + if (typeof value !== 'number' || !Number.isFinite(value)) return field.default; + if (field.min !== undefined && value < field.min) return field.min; + if (field.max !== undefined && value > field.max) return field.max; + return value; + } + default: + return field.default; + } +} + +/** + * Settings as the plugin should see them. Without a schema the stored + * values pass through unchanged (older plugins read free-form settings). + * With one, every declared field has a valid value and nothing else is kept, + * so a field removed in an update or an option that no longer exists can't + * linger. + */ +export function effectiveSettings(plugin: Pick): Record { + const stored = plugin.settings ?? {}; + const schema = plugin.settingsSchema; + if (!schema || Object.keys(schema).length === 0) return { ...stored }; + const out: Record = {}; + for (const [key, field] of Object.entries(schema)) { + out[key] = key in stored ? coerce(field, stored[key]) : field.default; + } + return out; +} + +/** A single value as it will be stored, or the field's default if invalid. */ +export function coerceSetting(field: SettingFieldSchema, value: unknown): unknown { + return coerce(field, value); +} diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 28fcd32fd..3917a5358 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -15,6 +15,8 @@ import { packageHash, type ValidatedPluginManifest } from '@/lib/extension-packa import JSZip from 'jszip'; import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity'; import { downloadManagedBundle } from '@/lib/plugin-sandbox/bundle-fetch'; +import { forEachInstance } from '@/lib/plugin-sandbox/host-bridge'; +import { coerceSetting, effectiveSettings } from '@/lib/plugin-settings'; import { usePolicyStore } from '@/stores/policy-store'; import { apiFetch } from '@/lib/browser-navigation'; import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; @@ -238,11 +240,20 @@ export const usePluginStore = create()( updatePluginSettings: (id: string, settings: Record) => { const { plugins } = get(); - set({ - plugins: plugins.map(p => - p.id === id ? { ...p, settings: { ...p.settings, ...settings } } : p - ), - }); + const plugin = plugins.find(p => p.id === id); + if (!plugin) return; + // Keep only values the schema accepts (numbers within min/max, an + // existing select option), so what is stored is what the plugin gets. + const schema = plugin.settingsSchema ?? {}; + const accepted: Record = {}; + for (const [key, value] of Object.entries(settings)) { + accepted[key] = schema[key] ? coerceSetting(schema[key], value) : value; + } + const updated = { ...plugin, settings: { ...plugin.settings, ...accepted } }; + set({ plugins: plugins.map(p => (p.id === id ? updated : p)) }); + // Running iframes get the change right away; no reload needed. + const next = effectiveSettings(updated); + forEachInstance(id, (instance) => instance.setSettings(next)); }, setPluginStatus: (id: string, status: PluginStatus, error?: string) => { From a21dbc92ece5de56d8600bef315f657c7cf4c5de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:23:40 +0200 Subject: [PATCH 013/147] fix: switch running plugins to a new language right away and fall back from de-CH to de --- lib/__tests__/plugin-i18n.test.ts | 32 ++++++++++++ lib/__tests__/plugin-sandbox-loader.test.ts | 16 +++++- lib/plugin-sandbox/loader.ts | 11 +++-- lib/plugin-sandbox/plugin-i18n.ts | 40 +++++++++++++++ lib/plugin-sandbox/runtime.tsx | 54 +++++++++++++++------ 5 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 lib/__tests__/plugin-i18n.test.ts create mode 100644 lib/plugin-sandbox/plugin-i18n.ts diff --git a/lib/__tests__/plugin-i18n.test.ts b/lib/__tests__/plugin-i18n.test.ts new file mode 100644 index 000000000..eced2762a --- /dev/null +++ b/lib/__tests__/plugin-i18n.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { lookup, interpolate, localeChain } from '../plugin-sandbox/plugin-i18n'; + +const tables = { + en: { hello: 'Hello {name}', only_en: 'English only' }, + de: { hello: 'Hallo {name}' }, + 'zh-TW': { hello: '你好 {name}' }, +}; + +describe('plugin translations', () => { + it('falls back from a regional locale to its language', () => { + // de-CH used to skip straight to English. + expect(lookup(tables, 'de-CH', 'hello')).toBe('Hallo {name}'); + }); + + it('matches locale names regardless of case', () => { + expect(lookup(tables, 'zh-tw', 'hello')).toBe('你好 {name}'); + }); + + it('falls back to English, then reports a missing key', () => { + expect(lookup(tables, 'de', 'only_en')).toBe('English only'); + expect(lookup(tables, 'de', 'missing')).toBeUndefined(); + }); + + it('builds the chain most specific first', () => { + expect(localeChain('pt-BR')).toEqual(['pt-BR', 'pt', 'en']); + }); + + it('fills placeholders', () => { + expect(interpolate('Hallo {name}', { name: 'Ana' })).toBe('Hallo Ana'); + }); +}); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 1df200de3..70e0b3b03 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ downloadManagedBundle: vi.fn(), createBackgroundInstance: vi.fn(), registerShortcuts: vi.fn((..._args: unknown[]) => () => {}), + setLocale: vi.fn(), })); vi.mock('../plugin-storage', () => ({ @@ -22,6 +23,7 @@ vi.mock('../plugin-sandbox/bundle-fetch', () => ({ })); vi.mock('../plugin-sandbox/host-bridge', () => ({ createBackgroundInstance: (...args: unknown[]) => mocks.createBackgroundInstance(...args), + forEachInstance: (_id: string | null, fn: (i: { setLocale: (l: string) => void }) => void) => fn({ setLocale: mocks.setLocale }), SandboxInstance: class {}, })); vi.mock('../plugin-sandbox/host-api', () => ({ @@ -31,7 +33,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: (...args: unknown[]) => mocks.registerShortcuts(...args), })); -import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader'; +import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor, setSandboxLocale } from '../plugin-sandbox/loader'; import { get as getActive } from '../plugin-sandbox/registry'; const CODE = 'export default { activate() {} }'; @@ -205,3 +207,15 @@ describe('plugin shortcuts', () => { expect(mocks.registerShortcuts).toHaveBeenCalledWith(expect.anything(), SHORTCUT); }); }); + +describe('setSandboxLocale', () => { + it('switches running plugins to the new language', () => { + // Running plugins used to keep the old language until a reload. + setSandboxLocale('en'); + mocks.setLocale.mockClear(); + + setSandboxLocale('de'); + + expect(mocks.setLocale).toHaveBeenCalledWith('de'); + }); +}); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 679661ae4..d7ecf6fe0 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -16,7 +16,7 @@ import { import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; import { verifyBundle } from './bundle-integrity'; import { downloadManagedBundle } from './bundle-fetch'; -import { createBackgroundInstance } from './host-bridge'; +import { createBackgroundInstance, forEachInstance } from './host-bridge'; import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; @@ -51,12 +51,17 @@ export function setSandboxLocale(locale: string): void { // good locale back to '' - the initial 'en' default stands until the real // locale arrives via the store subscription. if (!locale) return; + const changed = locale !== currentLocale; currentLocale = locale; // Background instances read `currentLocale` at load time; the slot-iframe // component reads this global at spawn time (plugin-iframe-slot.tsx). Keep - // both in step from one place. Already-running instances are not re-pushed, - // so a locale switch only affects plugins/slots loaded afterwards. + // both in step from one place. (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale; + if (!changed) return; + // Running plugins switch too: their slots re-render in the new language + // and their onLocaleChange handlers run. + forEachInstance(null, (instance) => instance.setLocale(locale)); + void themeHooks.onLocaleChange.emit({ locale }); } // ─── Bundle fetch ───────────────────────────────────────────── diff --git a/lib/plugin-sandbox/plugin-i18n.ts b/lib/plugin-sandbox/plugin-i18n.ts new file mode 100644 index 000000000..52d84283c --- /dev/null +++ b/lib/plugin-sandbox/plugin-i18n.ts @@ -0,0 +1,40 @@ +// Translation lookup for plugin strings (api.i18n.t). Pure, so the runtime +// inside the sandbox and the tests share it. + +export type LocaleTables = Record>; + +/** + * Locales to try for `locale`, most specific first: `de-CH` → `de-CH`, `de`, + * then `en`. Matching ignores case (`zh-tw` finds `zh-TW`). + */ +export function localeChain(locale: string): string[] { + const chain: string[] = []; + const parts = (locale || 'en').split('-'); + for (let i = parts.length; i > 0; i--) chain.push(parts.slice(0, i).join('-')); + if (!chain.includes('en')) chain.push('en'); + return chain; +} + +function findTable(tables: LocaleTables, locale: string): Record | undefined { + if (tables[locale]) return tables[locale]; + const lower = locale.toLowerCase(); + const key = Object.keys(tables).find((k) => k.toLowerCase() === lower); + return key ? tables[key] : undefined; +} + +/** The text for `key`, or undefined when no table has it. */ +export function lookup(tables: LocaleTables, locale: string, key: string): string | undefined { + for (const loc of localeChain(locale)) { + const value = findTable(tables, loc)?.[key]; + if (typeof value === 'string') return value; + } + return undefined; +} + +/** Replace `{name}` placeholders. */ +export function interpolate(text: string, vars?: Record): string { + if (!vars) return text; + let out = text; + for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v)); + return out; +} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 03970b477..93982f340 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -28,6 +28,7 @@ import type { SlotInit, } from './protocol'; import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; +import { interpolate, lookup, type LocaleTables } from './plugin-i18n'; import type { SlotName } from '../plugin-types'; import { AddressBook, ContactCard } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store'; @@ -66,6 +67,19 @@ const pendingApi = new Map void; reject: (err // The plugin's settings. One object for the iframe's lifetime, updated in // place on 'settings-change', so code that kept a reference sees new values. const settingsState: Record = {}; + +// The app language, pushed by the host on init and on every change. +const localeListeners = new Set<(locale: string) => void>(); +function currentLocale(): string { + return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; +} +function applyLocale(locale: string): void { + (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale; + document.documentElement.lang = locale; + for (const listener of localeListeners) { + try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); } + } +} const settingsListeners = new Set<(settings: Record) => void>(); function applySettings(next: Record): void { @@ -229,6 +243,7 @@ interface PluginKeywordCounts { function buildPluginApi(manifest: PluginManifest) { applySettings({ ...manifest.settings }); + const localeTables: LocaleTables = JSON.parse(JSON.stringify(manifest.locales ?? {})); return { plugin: { id: manifest.id, @@ -495,22 +510,27 @@ function buildPluginApi(manifest: PluginManifest) { }, // Localization for plugins. The host pushes the active locale (init + // 'locale-change'); `t` resolves a key against the plugin's declared - // `locales` map (manifest.locales), falling back to English then the key - // itself, with optional {placeholder} interpolation. + // `locales` map (manifest.locales) plus anything added at runtime, + // trying `de-CH`, then `de`, then English, then the key itself, with + // optional {placeholder} interpolation. i18n: { get locale(): string { - return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; + return currentLocale(); }, + getLocale: () => currentLocale(), t(key: string, vars?: Record): string { - const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; - const tables = manifest.locales || {}; - let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key; - if (vars) { - for (const [k, v] of Object.entries(vars)) { - out = out.split('{' + k + '}').join(String(v)); - } - } - return out; + return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars); + }, + /** Whether any table has `key`, for `has(k) ? t(k) : fallback`. */ + has: (key: string) => lookup(localeTables, currentLocale(), key) !== undefined, + /** Add or override strings for one locale. */ + addTranslations(locale: string, strings: Record): void { + localeTables[locale] = { ...(localeTables[locale] ?? {}), ...strings }; + }, + /** Called with the new locale when the user switches language. Returns an unsubscribe function. */ + onLocaleChange: (listener: (locale: string) => void) => { + localeListeners.add(listener); + return () => { localeListeners.delete(listener); }; }, }, }; @@ -664,7 +684,11 @@ function bootSlot(payload: SlotInit): void { React.useEffect(() => { const bump = () => setSettingsVersion((v) => v + 1); settingsListeners.add(bump); - return () => { settingsListeners.delete(bump); }; + localeListeners.add(bump); + return () => { + settingsListeners.delete(bump); + localeListeners.delete(bump); + }; }, []); React.useEffect(() => { if (!wrapRef.current) return; @@ -698,7 +722,7 @@ async function handleInit(payload: InitPayload): Promise { mode = payload.mode; // Make the active locale available to plugin code (api.i18n) right away - // not only after the first 'locale-change' push. - (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale; + applyLocale(payload.locale); try { if (payload.mode === 'background') { await bootBackground(payload); @@ -780,7 +804,7 @@ function handleHostMessage(ev: MessageEvent): void { } case 'locale-change': - (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale; + applyLocale(msg.locale); break; case 'theme-change': From bb562362b8bd0ff86ee211448c7810d6280db944 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:24:45 +0200 Subject: [PATCH 014/147] fix: lay out plugin slots right-to-left in Arabic, Hebrew and Persian and use the app font size --- lib/__tests__/host-theme.test.ts | 5 +++++ lib/__tests__/plugin-i18n.test.ts | 11 ++++++++++- lib/plugin-sandbox/host-theme.ts | 2 ++ lib/plugin-sandbox/plugin-i18n.ts | 7 +++++++ lib/plugin-sandbox/runtime.tsx | 5 ++++- 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/host-theme.test.ts b/lib/__tests__/host-theme.test.ts index 4542480e0..b3c480c3f 100644 --- a/lib/__tests__/host-theme.test.ts +++ b/lib/__tests__/host-theme.test.ts @@ -19,6 +19,11 @@ describe('host-theme', () => { expect(css).toContain('background: transparent;'); }); + it("applies the user's font size so rem units in a slot match the app", () => { + const css = themeSnapshotToCSS({ dark: false, fontFamily: 'x', vars: { '--font-size-base': '17px' } }); + expect(css).toMatch(/:root \{[^}]*font-size: var\(--font-size-base\);/); + }); + it('reports a dark color-scheme when dark', () => { const css = themeSnapshotToCSS({ dark: true, fontFamily: 'sans-serif', vars: {} }); expect(css).toContain('color-scheme: dark;'); diff --git a/lib/__tests__/plugin-i18n.test.ts b/lib/__tests__/plugin-i18n.test.ts index eced2762a..b1cc8a858 100644 --- a/lib/__tests__/plugin-i18n.test.ts +++ b/lib/__tests__/plugin-i18n.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { lookup, interpolate, localeChain } from '../plugin-sandbox/plugin-i18n'; +import { lookup, interpolate, localeChain, textDirection } from '../plugin-sandbox/plugin-i18n'; const tables = { en: { hello: 'Hello {name}', only_en: 'English only' }, @@ -30,3 +30,12 @@ describe('plugin translations', () => { expect(interpolate('Hallo {name}', { name: 'Ana' })).toBe('Hallo Ana'); }); }); + +describe('textDirection', () => { + it('is right-to-left for Arabic, Hebrew and Persian, with or without a region', () => { + expect(textDirection('ar')).toBe('rtl'); + expect(textDirection('he-IL')).toBe('rtl'); + expect(textDirection('fa')).toBe('rtl'); + expect(textDirection('de-CH')).toBe('ltr'); + }); +}); diff --git a/lib/plugin-sandbox/host-theme.ts b/lib/plugin-sandbox/host-theme.ts index 7b1b0423a..a97a8d491 100644 --- a/lib/plugin-sandbox/host-theme.ts +++ b/lib/plugin-sandbox/host-theme.ts @@ -121,6 +121,8 @@ export function themeSnapshotToCSS(snapshot: ThemeSnapshot): string { ':root {', declarations, ` color-scheme: ${colorScheme};`, + // The user's font size setting, so rem units in a slot match the app. + ...(snapshot.vars['--font-size-base'] ? [' font-size: var(--font-size-base);'] : []), '}', 'html, body {', ` font-family: ${snapshot.fontFamily};`, diff --git a/lib/plugin-sandbox/plugin-i18n.ts b/lib/plugin-sandbox/plugin-i18n.ts index 52d84283c..b3d64d138 100644 --- a/lib/plugin-sandbox/plugin-i18n.ts +++ b/lib/plugin-sandbox/plugin-i18n.ts @@ -1,8 +1,15 @@ // Translation lookup for plugin strings (api.i18n.t). Pure, so the runtime // inside the sandbox and the tests share it. +import { getLocaleDirection } from '@/i18n/direction'; + export type LocaleTables = Record>; +/** Text direction for a locale tag, `ar-EG` included. */ +export function textDirection(locale: string): 'ltr' | 'rtl' { + return getLocaleDirection((locale || 'en').split('-')[0].toLowerCase()); +} + /** * Locales to try for `locale`, most specific first: `de-CH` → `de-CH`, `de`, * then `en`. Matching ignores case (`zh-tw` finds `zh-TW`). diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 93982f340..20f02b1ef 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -28,7 +28,7 @@ import type { SlotInit, } from './protocol'; import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; -import { interpolate, lookup, type LocaleTables } from './plugin-i18n'; +import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import type { SlotName } from '../plugin-types'; import { AddressBook, ContactCard } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store'; @@ -76,6 +76,9 @@ function currentLocale(): string { function applyLocale(locale: string): void { (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale; document.documentElement.lang = locale; + // Slots sit inside an RTL page in Arabic, Hebrew and Persian; their own + // document has to flip too. + document.documentElement.dir = textDirection(locale); for (const listener of localeListeners) { try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); } } From 9408daaf5e48445928842f17057c2e0d057cedbf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:25:45 +0200 Subject: [PATCH 015/147] fix: keep an extension's admin state on re-upload and remove its config when it is deleted --- app/api/admin/plugins/__tests__/route.test.ts | 27 ++++++++++++++++++- app/api/admin/plugins/route.ts | 9 ++++++- app/api/admin/themes/route.ts | 7 +++-- lib/admin/extension-cleanup.ts | 11 ++++++++ lib/admin/plugin-config.ts | 6 +++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/api/admin/plugins/__tests__/route.test.ts b/app/api/admin/plugins/__tests__/route.test.ts index 264a0badd..9d2ce079b 100644 --- a/app/api/admin/plugins/__tests__/route.test.ts +++ b/app/api/admin/plugins/__tests__/route.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ invalidateFrameOriginsCache: vi.fn(), })); -import { POST } from '../route'; +import { DELETE, POST } from '../route'; const MANIFEST = { id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd', @@ -100,3 +100,28 @@ describe('POST /api/admin/plugins', () => { expect(res.status).toBe(400); }); }); + +describe('re-uploading and deleting', () => { + it('keeps a plugin the admin disabled disabled when a new version is uploaded', async () => { + registry.set('demo-plugin', { id: 'demo-plugin', enabled: false, forceEnabled: true }); + + await upload({ 'manifest.json': JSON.stringify({ ...MANIFEST, version: '1.1.0' }), 'index.js': '' }); + + expect(registry.get('demo-plugin')).toMatchObject({ version: '1.1.0', enabled: false, forceEnabled: true }); + }); + + it('drops the config and the forced flag when a plugin is deleted', async () => { + registry.set('demo-plugin', { id: 'demo-plugin' }); + policy = { forceEnabledPlugins: ['demo-plugin', 'other'] }; + + const res = await DELETE(new NextRequest('http://localhost/api/admin/plugins', { + method: 'DELETE', + body: JSON.stringify({ id: 'demo-plugin' }), + headers: { 'content-type': 'application/json' }, + })); + + expect(res.status).toBe(200); + expect(removedConfig).toEqual(['demo-plugin']); + expect(policy.forceEnabledPlugins).toEqual(['other']); + }); +}); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 41542a25b..2573921ab 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -3,6 +3,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { + getPlugin, getPluginRegistry, savePlugin, deletePlugin as removePlugin, @@ -10,6 +11,7 @@ import { } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; +import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup'; import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; @@ -86,6 +88,9 @@ export async function POST(request: NextRequest) { ); } + // A new upload of an installed plugin is an update: keep whether the + // admin had it enabled or forced instead of switching it on for everyone. + const existing = await getPlugin(manifest.id); const now = new Date().toISOString(); const plugin: ServerPlugin = { id: manifest.id, @@ -97,7 +102,8 @@ export async function POST(request: NextRequest) { ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions: manifest.permissions, entrypoint: manifest.entrypoint, - enabled: true, + enabled: existing?.enabled ?? true, + ...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}), ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), ...(manifest.locales ? { locales: manifest.locales } : {}), @@ -193,6 +199,7 @@ export async function DELETE(request: NextRequest) { if (!removed) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); } + await cleanUpDeletedPlugin(id); invalidateFrameOriginsCache(); await auditLog('plugin.delete', { id }, ip); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 9f07bf34f..3950021aa 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -3,6 +3,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { + getTheme, getThemeRegistry, saveTheme, deleteTheme as removeTheme, @@ -55,6 +56,7 @@ export async function POST(request: NextRequest) { } const { manifest, css, skin, warnings } = read; + const existing = await getTheme(manifest.id); const now = new Date().toISOString(); const theme: ServerTheme = { id: manifest.id, @@ -63,8 +65,9 @@ export async function POST(request: NextRequest) { author: manifest.author, description: manifest.description || '', variants: manifest.variants, - enabled: true, - installedAt: now, + enabled: existing?.enabled ?? true, + ...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}), + installedAt: existing?.installedAt ?? now, updatedAt: now, }; diff --git a/lib/admin/extension-cleanup.ts b/lib/admin/extension-cleanup.ts index 052e4a2f8..00b70529c 100644 --- a/lib/admin/extension-cleanup.ts +++ b/lib/admin/extension-cleanup.ts @@ -1,4 +1,5 @@ import { configManager } from './config-manager'; +import { deletePluginConfig } from './plugin-config'; /** * Drop a deleted plugin or theme from the policy lists that refer to it by @@ -13,3 +14,13 @@ export async function forgetExtensionInPolicy(kind: 'plugin' | 'theme', id: stri if (!list.includes(id)) return; await configManager.setPolicy({ ...policy, [key]: list.filter((x) => x !== id) }); } + +/** + * Everything the server keeps about an admin plugin besides its registry + * entry: the forced flag and its config (which can hold secrets). A plugin + * installed later under the same id starts clean. + */ +export async function cleanUpDeletedPlugin(id: string): Promise { + await forgetExtensionInPolicy('plugin', id); + await deletePluginConfig(id); +} diff --git a/lib/admin/plugin-config.ts b/lib/admin/plugin-config.ts index ca78e1d9d..3af07d254 100644 --- a/lib/admin/plugin-config.ts +++ b/lib/admin/plugin-config.ts @@ -51,6 +51,12 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn await rename(tmpPath, filePath); } +/** Remove all stored config, secrets included, when a plugin is deleted. */ +export async function deletePluginConfig(pluginId: string): Promise { + assertWritable('delete plugin config'); + try { await unlink(configPath(pluginId)); } catch { /* none stored */ } +} + /** * Delete a single config key for a plugin. */ From c4853a4bba163f3ec04c2e6918384f3548ec5a5c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:26:56 +0200 Subject: [PATCH 016/147] fix: check dev-directory plugins like uploads and keep their files inside the plugin folder --- lib/admin/__tests__/plugin-dev.test.ts | 57 +++++++++++++++++++ lib/admin/csp-frame-origins.ts | 6 +- lib/admin/plugin-dev.ts | 79 +++++++++++++++----------- 3 files changed, 107 insertions(+), 35 deletions(-) create mode 100644 lib/admin/__tests__/plugin-dev.test.ts diff --git a/lib/admin/__tests__/plugin-dev.test.ts b/lib/admin/__tests__/plugin-dev.test.ts new file mode 100644 index 000000000..d060b798b --- /dev/null +++ b/lib/admin/__tests__/plugin-dev.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; + +let dir: string; + +async function devPlugin(folder: string, manifest: Record, files: Record = { 'index.js': 'module.exports = {}' }) { + const p = path.join(dir, 'plugins', folder); + await mkdir(p, { recursive: true }); + await writeFile(path.join(p, 'manifest.json'), JSON.stringify(manifest)); + for (const [name, body] of Object.entries(files)) { + await mkdir(path.dirname(path.join(p, name)), { recursive: true }); + await writeFile(path.join(p, name), body); + } +} + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-dev-')); + await mkdir(path.join(dir, 'config'), { recursive: true }); + vi.stubEnv('PLUGIN_DEV_DIR', path.join(dir, 'plugins')); + vi.stubEnv('ADMIN_CONFIG_DIR', path.join(dir, 'config')); + vi.resetModules(); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +const BASE = { name: 'Dev', version: '1.0.0', author: 'me', type: 'ui-extension', entrypoint: 'index.js' }; + +describe('dev plugins', () => { + it('keeps locales, tier and frame origins', async () => { + await devPlugin('demo', { + ...BASE, id: 'demo', permissions: ['crypto:full'], tier: 'privileged', + locales: { de: { hi: 'Hallo' } }, frameOrigins: ['https://video.example.com'], + }); + const { listDevPlugins } = await import('../plugin-dev'); + const { getEnabledPluginFrameOrigins } = await import('../csp-frame-origins'); + + const [entry] = await listDevPlugins(); + + expect(entry.plugin.locales).toEqual({ de: { hi: 'Hallo' } }); + expect(entry.plugin.tier).toBe('privileged'); + expect(await getEnabledPluginFrameOrigins()).toContain('https://video.example.com'); + }); + + it('refuses an entrypoint outside the plugin folder', async () => { + // The bundle route serves the file without a session. + await writeFile(path.join(dir, 'secret.js'), 'TOP SECRET'); + await devPlugin('escape', { ...BASE, id: 'escape', entrypoint: '../../secret.js' }); + const { listDevPlugins } = await import('../plugin-dev'); + + expect(await listDevPlugins()).toEqual([]); + }); +}); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 9e02c7f1b..b28c40616 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -41,9 +41,13 @@ export async function getEnabledPluginFrameOrigins(): Promise { try { const registry = await getPluginRegistry(); + // Dev plugins (PLUGIN_DEV_DIR) are served next to the installed ones and + // need their embeds allowed too. + const { listDevPlugins } = await import('./plugin-dev'); + const devPlugins = (await listDevPlugins()).map((e) => e.plugin); const seen = new Set(); const out: string[] = []; - for (const plugin of registry.plugins) { + for (const plugin of [...registry.plugins, ...devPlugins]) { if (!plugin.enabled) continue; const origins = sanitizeFrameOrigins(plugin.frameOrigins); for (const o of origins) { diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 84863a5c0..76f65b6c3 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; import type { ServerPlugin } from './plugin-registry'; -import { sanitizeFrameOrigins, sanitizeHttpOrigins, sanitizeApiPostPaths } from './csp-frame-origins'; +import { checkPluginManifest } from '@/lib/extension-package'; /** * Dev-mode plugin loading. @@ -64,16 +64,23 @@ interface ResolvedBundle { needsBundle: boolean; } +/** Whether `candidate` lies inside `dir` (after resolving `..`). */ +function isInside(dir: string, candidate: string): boolean { + const rel = path.relative(dir, path.resolve(candidate)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundle | null { - const srcCandidate = path.join(pluginDir, 'src', entrypoint); - if (existsSync(srcCandidate)) return { bundlePath: srcCandidate, needsBundle: true }; - - const rootCandidate = path.join(pluginDir, entrypoint); - if (existsSync(rootCandidate)) return { bundlePath: rootCandidate, needsBundle: false }; - - const distCandidate = path.join(pluginDir, 'dist', entrypoint); - if (existsSync(distCandidate)) return { bundlePath: distCandidate, needsBundle: false }; - + // The bundle route serves this file to anyone, so it must stay inside the + // plugin's own folder whatever the manifest says. + const candidates: Array<[string, boolean]> = [ + [path.join(pluginDir, 'src', entrypoint), true], + [path.join(pluginDir, entrypoint), false], + [path.join(pluginDir, 'dist', entrypoint), false], + ]; + for (const [candidate, needsBundle] of candidates) { + if (isInside(pluginDir, candidate) && existsSync(candidate)) return { bundlePath: candidate, needsBundle }; + } return null; } @@ -145,8 +152,22 @@ async function loadDevPlugin(pluginDir: string): Promise logger.warn(`[plugin-dev] manifest id "${id}" rejected by id regex (${manifestPath})`); return null; } + // Same checks as an upload, with the fields a work-in-progress manifest + // may leave out filled in. + const check = checkPluginManifest({ + version: '0.0.0', + author: 'dev', + type: 'hook', + ...manifest, + name: asString(manifest.name, id) || id, + }); + if (!check.manifest) { + logger.warn(`[plugin-dev] ${id}: ${check.errors.join('; ')}`); + return null; + } + const valid = check.manifest; - const entrypoint = asString(manifest.entrypoint, 'index.js'); + const entrypoint = valid.entrypoint; const resolved = resolveBundlePath(pluginDir, entrypoint); if (!resolved) { logger.warn(`[plugin-dev] entrypoint "${entrypoint}" not found at src/, root, or dist/ for ${id}`); @@ -178,34 +199,24 @@ async function loadDevPlugin(pluginDir: string): Promise /* ignore */ } - const permissions = Array.isArray(manifest.permissions) - ? manifest.permissions.filter((p): p is string => typeof p === 'string') - : []; - - const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const apiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - const plugin: ServerPlugin = { id, - name: asString(manifest.name, id), - version: asString(manifest.version, '0.0.0-dev'), - author: asString(manifest.author), - description: asString(manifest.description), - type: asString(manifest.type, 'hook'), - permissions, + name: valid.name, + version: valid.version, + author: valid.author, + description: valid.description, + type: valid.type, + ...(valid.tier === 'privileged' ? { tier: 'privileged' } : {}), + permissions: valid.permissions, entrypoint, enabled: true, forceEnabled: false, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(frameOrigins.length > 0 ? { frameOrigins } : {}), - ...(httpOrigins.length > 0 ? { httpOrigins } : {}), - ...(apiPostPaths.length > 0 ? { apiPostPaths } : {}), + ...(valid.configSchema ? { configSchema: valid.configSchema } : {}), + ...(valid.settingsSchema ? { settingsSchema: valid.settingsSchema } : {}), + ...(valid.locales ? { locales: valid.locales } : {}), + ...(valid.frameOrigins ? { frameOrigins: valid.frameOrigins } : {}), + ...(valid.httpOrigins ? { httpOrigins: valid.httpOrigins } : {}), + ...(valid.apiPostPaths ? { apiPostPaths: valid.apiPostPaths } : {}), installedAt, updatedAt: new Date().toISOString(), bundleHash, From 15cec58a465cce13f1fdfb65aa5c756e2afe5442 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:27:35 +0200 Subject: [PATCH 017/147] fix: serve plugin bundles only to signed-in users --- .../[id]/bundle/__tests__/route.test.ts | 36 +++++++++++++++++++ app/api/admin/plugins/[id]/bundle/route.ts | 8 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts diff --git a/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts new file mode 100644 index 000000000..7c73cd2a3 --- /dev/null +++ b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let signedIn = false; +vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => signedIn) })); +vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: vi.fn(async () => null), readDevBundle: vi.fn() })); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPlugin: vi.fn(async () => ({ id: 'demo', enabled: true, bundleHash: 'h' })), + getPluginBundle: vi.fn(async () => 'module.exports = {}'), + getPluginSignature: vi.fn(async () => 'sig'), +})); +vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: vi.fn(async () => 'sig') })); + +import { GET } from '../route'; + +function get() { + return GET(new NextRequest('http://localhost/api/admin/plugins/demo/bundle'), { params: Promise.resolve({ id: 'demo' }) }); +} + +beforeEach(() => { signedIn = false; }); + +describe('GET plugin bundle', () => { + it('does not hand plugin code to someone who is not signed in', async () => { + expect((await get()).status).toBe(401); + }); + + it('serves it to a signed-in user with its signature', async () => { + signedIn = true; + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.get('x-bundle-signature')).toBe('sig'); + }); +}); diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index cb335a101..06b398ae5 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { signBytes } from '@/lib/admin/plugin-signing'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; async function safeSign(code: string): Promise { try { return await signBytes(code); } catch { return null; } @@ -10,13 +11,18 @@ async function safeSign(code: string): Promise { /** * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle * - * Public endpoint so the client-side plugin loader can fetch bundles. + * Serves bundles to signed-in users so the client-side plugin loader can fetch them. * Only serves plugins that exist in the registry and are enabled. */ export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { + // Bundles are delivered to signed-in users only, like the plugin list + // that names them (/api/plugins). + if (!(await canSeeInstanceDetails(request))) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } try { const { id } = await params; From 22846b4a9b52019db4000be9f6047daf945fcdcb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:28:11 +0200 Subject: [PATCH 018/147] fix: keep blob uploads privileged-only and derive the WebAuthn salt from the calling plugin --- lib/__tests__/plugin-api-gates.test.ts | 22 ++++++++++++++++++++++ lib/plugin-sandbox/host-api.ts | 8 ++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts index b267c495f..27a895eb6 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -305,3 +305,25 @@ describe('native keyword extension API', () => { } }); }); + +describe('privileged-only methods', () => { + it('refuses jmap.uploadBlob to an untrusted plugin even with email:blob-write', async () => { + const p = plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] }); + expect(await gateError(p, 'jmap.uploadBlob', [new Uint8Array(1), 'a.bin', 'application/octet-stream'])) + .toMatch(/requires the privileged plugin tier/); + }); +}); + +describe('WebAuthn PRF salt', () => { + it("uses the calling plugin's id, not the one in the arguments", async () => { + const get = vi.fn(async () => { throw new Error('stop'); }); + Object.defineProperty(navigator, 'credentials', { value: { get }, configurable: true }); + const p = plugin({ id: 'mine', permissions: ['crypto:full'], managed: true, tier: 'privileged' }); + + await dispatchApiCall(p, 'crypto.getWebAuthn', [[1, 2, 3], 'someone-else'], { privileged: true }).catch(() => {}); + + const salt = (get.mock.calls[0] as unknown as [{ publicKey: { extensions: { prf: { eval: { first: Uint8Array } } } } }])[0] + .publicKey.extensions.prf.eval.first; + expect(new TextDecoder().decode(salt)).toBe('bulwark-plugins-v1mine'); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index cf64ec690..26a0ab710 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -41,6 +41,8 @@ import { createHash } from 'crypto'; */ const PRIVILEGED_ONLY_METHODS = new Set([ 'jmap.fetchBlob', + // Puts arbitrary bytes on the mail server. Untrusted plugins attach files + // through the composer and draft APIs instead, which bound and label them. 'jmap.uploadBlob', 'jmap.sendRaw', 'jmap.submitRaw', @@ -1556,8 +1558,10 @@ export async function dispatchApiCall( case 'upfiles.get' : return getFile(args[0] as string); case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File); - case 'crypto.createWebAuthn': return doCreatePRF(args[0] as string, args[1] as string, args[2] as string); - case 'crypto.getWebAuthn': return doGetPRF(args[0] as number[], args[1] as string); + // The PRF salt is derived from the calling plugin's id, never from an + // argument: otherwise one plugin could ask for another plugin's secret. + case 'crypto.createWebAuthn': return doCreatePRF(plugin.id, args[1] as string, args[2] as string); + case 'crypto.getWebAuthn': return doGetPRF(args[0] as number[], plugin.id); case 'crypto.getPublicKeys': return getPublicKeys(); case 'crypto.createPublicKey': return doCreatePublicKey(args[0] as PublicKeyInput); case 'crypto.removePublicKey': return doRemovePublicKey(args[0] as string); From 23c4c654d733cda4e699662d3b668034af4e472c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:30:39 +0200 Subject: [PATCH 019/147] fix: limit what a plugin can store, download, toast and send to a WKD lookup, and report refused admin config writes --- lib/__tests__/plugin-host-api-limits.test.ts | 94 +++++++++++++ lib/plugin-sandbox/host-api.ts | 138 +++++++++++++++---- 2 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 lib/__tests__/plugin-host-api-limits.test.ts diff --git a/lib/__tests__/plugin-host-api-limits.test.ts b/lib/__tests__/plugin-host-api-limits.test.ts new file mode 100644 index 000000000..5952fbb49 --- /dev/null +++ b/lib/__tests__/plugin-host-api-limits.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: (...args: unknown[]) => fetchMock(...args), +})); + +import { dispatchApiCall, PLUGIN_STORAGE_QUOTA_BYTES, safeDownloadName, doGetPublicKeyFromWKD } from '../plugin-sandbox/host-api'; + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'limits', name: 'Limits', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, + ...overrides, + }; +} + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('plugin storage quota', () => { + it('refuses a write that would take the plugin past its share', async () => { + // Without a cap one plugin could fill localStorage and break sign-in. + const half = 'x'.repeat(PLUGIN_STORAGE_QUOTA_BYTES / 2); + await dispatchApiCall(plugin(), 'storage.set', ['a', half]); + + await expect(dispatchApiCall(plugin(), 'storage.set', ['b', half])).rejects.toThrow(/storage is full/); + }); + + it('lets a plugin overwrite a key within its share', async () => { + const big = 'x'.repeat(PLUGIN_STORAGE_QUOTA_BYTES * 0.8); + await dispatchApiCall(plugin(), 'storage.set', ['a', big]); + await expect(dispatchApiCall(plugin(), 'storage.set', ['a', big])).resolves.toBeUndefined(); + }); +}); + +describe('ui.downloadFile', () => { + const p = plugin({ permissions: ['ui:download-file'], grantedPermissions: ['ui:download-file'] }); + + it.each([ + ['invoice.pdf.exe', 'application/pdf'], + ['page.html', 'text/plain'], + ['report.txt', 'text/html'], + ['logo.svg', 'image/svg+xml'], + ])('refuses %s (%s)', async (filename, contentType) => { + await expect(dispatchApiCall(p, 'ui.downloadFile', [{ content: 'x', filename, contentType }])) + .rejects.toThrow(/can't be downloaded/); + }); + + it('strips paths and direction tricks from the name', () => { + expect(safeDownloadName('../../etc/passwd')).toBe('_.._etc_passwd'); + expect(safeDownloadName('invoice‮fdp.exe')).toBe('invoicefdp.exe'); + expect(safeDownloadName('')).toBe('download'); + }); +}); + +describe('toasts', () => { + it('caps how many toasts a plugin shows in a burst', async () => { + for (let i = 0; i < 20; i++) await dispatchApiCall(plugin(), 'toast.info', [`n${i}`]); + expect(toast.info).toHaveBeenCalledTimes(5); + }); + + it('shortens very long messages', async () => { + await dispatchApiCall(plugin({ id: 'long' }), 'toast.error', ['x'.repeat(5000)]); + expect((toast.error.mock.calls[0][0] as string).length).toBeLessThanOrEqual(300); + }); +}); + +describe('admin config writes', () => { + it('tells the plugin when the server refused the write', async () => { + // A 403 used to look like success. + fetchMock.mockResolvedValue(new Response('{}', { status: 403 })); + const p = plugin({ permissions: ['admin:config'], grantedPermissions: ['admin:config'] }); + await expect(dispatchApiCall(p, 'admin.setConfig', ['k', 'v'])).rejects.toThrow(/only an administrator/); + }); +}); + +describe('WKD lookup', () => { + it('refuses an address whose domain is not a host name', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + expect(await doGetPublicKeyFromWKD('a@evil.example/x?')).toMatchObject({ status: 'ERROR' }); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 26a0ab710..d437bb2c7 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -213,9 +213,32 @@ function storageGet(pluginId: string, key: string): unknown { if (raw === null) return null; try { return JSON.parse(raw); } catch { return null; } } +/** + * What one plugin may keep per account in localStorage. The host's own + * stores (sign-in, settings) live in the same 5-10 MB browser quota, so a + * plugin that filled it would break them. + */ +export const PLUGIN_STORAGE_QUOTA_BYTES = 1024 * 1024; + function storageSet(pluginId: string, key: string, value: unknown): void { if (typeof window === 'undefined') return; - window.localStorage.setItem(storagePrefix(pluginId) + key, JSON.stringify(value)); + if (typeof key !== 'string' || key.length === 0 || key.length > 256) { + throw new Error('storage.set: key must be a string of 1-256 characters'); + } + const prefix = storagePrefix(pluginId); + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new Error('storage.set: value is not JSON-serialisable'); + let used = 0; + for (let i = 0; i < window.localStorage.length; i++) { + const k = window.localStorage.key(i); + if (!k?.startsWith(prefix) || k === prefix + key) continue; + used += k.length + (window.localStorage.getItem(k)?.length ?? 0); + } + // UTF-16 code units, which is what browsers count against the quota. + if (used + prefix.length + key.length + serialized.length > PLUGIN_STORAGE_QUOTA_BYTES) { + throw new Error(`Plugin storage is full (${Math.round(PLUGIN_STORAGE_QUOTA_BYTES / 1024)} KB per account)`); + } + window.localStorage.setItem(prefix + key, serialized); } function storageRemove(pluginId: string, key: string): void { if (typeof window === 'undefined') return; @@ -1003,6 +1026,11 @@ export async function doGetPublicKeyFromWKD(email: string): Promise { const localPart = cleanEmail.slice(0, atIndex); const domain = cleanEmail.slice(atIndex + 1).toLowerCase(); + // The domain goes into a URL; anything but a host name would change where + // the request goes (a@evil.example/x?). + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) { + return { status: 'ERROR', error: 'Invalid email address' }; + } const hash = getWkdHash(localPart); const urls = [ @@ -1063,21 +1091,47 @@ async function saveFile(formerFileID:string, file: File): Promise { } // ─── Download files generated by the plugin. This is not user's files or attachments ────────────────────────── -async function downloadFile(args: { content: string; filename: string; contentType?: string }): Promise { - const { content, filename, contentType = 'application/json' } = args; +// File types a plugin may not hand the user: programs, scripts, and +// documents a browser opens as an active page. +const BLOCKED_DOWNLOAD_EXTENSIONS = new Set([ + 'exe', 'msi', 'bat', 'cmd', 'com', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'hta', 'jar', + 'lnk', 'app', 'dmg', 'pkg', 'sh', 'command', 'html', 'htm', 'xhtml', 'shtml', 'svg', 'mhtml', 'xml', +]); +const BLOCKED_DOWNLOAD_TYPES = /^(text\/html|application\/xhtml|image\/svg|application\/(x-)?javascript|text\/javascript|application\/x-msdownload|application\/xml|text\/xml)/i; - try { - const url = URL.createObjectURL(new Blob([content], { type: contentType })); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch (error) { - throw new Error(`Failed to download file: ${error}`); - } +/** A file name that can't carry a path, control characters or a hidden extension. */ +export function safeDownloadName(name: unknown): string { + const raw = typeof name === 'string' ? name : ''; + const cleaned = raw + // eslint-disable-next-line no-control-regex -- removing control characters is the point + .replace(/[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '') + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/^[.\s]+|[.\s]+$/g, '') + .slice(0, 120); + return cleaned || 'download'; +} + +async function downloadFile(args: { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }): Promise { + const { content } = args; + const filename = safeDownloadName(args.filename); + const contentType = typeof args.contentType === 'string' && args.contentType ? args.contentType : 'application/octet-stream'; + const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.') + 1).toLowerCase() : ''; + if (BLOCKED_DOWNLOAD_EXTENSIONS.has(ext) || BLOCKED_DOWNLOAD_TYPES.test(contentType)) { + throw new Error(`ui.downloadFile: ${ext ? `.${ext} files` : contentType} can't be downloaded from a plugin`); + } + if (typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array)) { + throw new Error('ui.downloadFile: content must be a string or bytes'); + } + const url = URL.createObjectURL(new Blob([content as BlobPart], { type: contentType })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Revoking at once can cancel the download in some browsers. + setTimeout(() => URL.revokeObjectURL(url), 60_000); } // ─── Email keyword mutations ────────────────────────────────── @@ -1462,18 +1516,52 @@ async function adminGet(pluginId: string, key: string): Promise { return all[key] ?? null; } async function adminSet(pluginId: string, key: string, value: unknown): Promise { - await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { + await expectOk('admin.setConfig', apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }), - }); + })); +} + +/** A failed admin config write used to look like success to the plugin. */ +async function expectOk(method: string, request: Promise): Promise { + const res = await request; + if (!res.ok) { + throw new Error(res.status === 401 || res.status === 403 + ? `${method}: only an administrator can change this plugin's admin settings` + : `${method} failed (HTTP ${res.status})`); + } } async function adminDelete(pluginId: string, key: string): Promise { - await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { + await expectOk('admin.deleteConfig', apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), - }); + })); +} + +// ─── Toasts ────────────────────────────────────────────────── + +const MAX_TOAST_LENGTH = 300; +const TOASTS_PER_TEN_SECONDS = 5; +const recentToasts = new Map(); + +function toastText(value: unknown): string { + const text = String(value ?? ''); + return text.length > MAX_TOAST_LENGTH ? text.slice(0, MAX_TOAST_LENGTH - 1) + '…' : text; +} + +/** A plugin may show a few toasts; past that they are dropped, not queued. */ +function mayToast(pluginId: string): boolean { + const now = Date.now(); + const recent = (recentToasts.get(pluginId) ?? []).filter((t) => now - t < 10_000); + if (recent.length >= TOASTS_PER_TEN_SECONDS) { + recentToasts.set(pluginId, recent); + return false; + } + recent.push(now); + recentToasts.set(pluginId, recent); + return true; } // ─── Dispatcher ────────────────────────────────────────────── @@ -1508,10 +1596,10 @@ export async function dispatchApiCall( case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined; case 'storage.keys': return storageKeys(plugin.id); - case 'toast.success': appToast.success(String(args[0] ?? '')); return undefined; - case 'toast.error': appToast.error(String(args[0] ?? '')); return undefined; - case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined; - case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined; + case 'toast.success': if (mayToast(plugin.id)) appToast.success(toastText(args[0])); return undefined; + case 'toast.error': if (mayToast(plugin.id)) appToast.error(toastText(args[0])); return undefined; + case 'toast.info': if (mayToast(plugin.id)) appToast.info(toastText(args[0])); return undefined; + case 'toast.warning': if (mayToast(plugin.id)) appToast.warning(toastText(args[0])); return undefined; case 'http.post': return doHttpPost(plugin, args[0] as string, args[1], args[2] as PluginHttpPostOptions | undefined); case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined); @@ -1681,8 +1769,8 @@ export async function dispatchApiCall( return undefined; } case 'ui.downloadFile': { - const opts = args[0] as { content: string; filename: string; contentType?: string }; - return downloadFile(opts); + const opts = args[0] as { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }; + return downloadFile(opts ?? { content: '', filename: '' }); } case 'email.setKeyword': { From a491eab317a7d23176f76cf7abb2c375ba011f50 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:31:39 +0200 Subject: [PATCH 020/147] fix: hash WKD names with WebCrypto instead of shipping a Node crypto polyfill to the browser --- lib/__tests__/plugin-host-api-limits.test.ts | 9 ++++++++- lib/plugin-sandbox/host-api.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/__tests__/plugin-host-api-limits.test.ts b/lib/__tests__/plugin-host-api-limits.test.ts index 5952fbb49..f4a44dafe 100644 --- a/lib/__tests__/plugin-host-api-limits.test.ts +++ b/lib/__tests__/plugin-host-api-limits.test.ts @@ -9,7 +9,7 @@ vi.mock('@/lib/browser-navigation', async (orig) => ({ apiFetch: (...args: unknown[]) => fetchMock(...args), })); -import { dispatchApiCall, PLUGIN_STORAGE_QUOTA_BYTES, safeDownloadName, doGetPublicKeyFromWKD } from '../plugin-sandbox/host-api'; +import { dispatchApiCall, PLUGIN_STORAGE_QUOTA_BYTES, safeDownloadName, doGetPublicKeyFromWKD, getWkdHash } from '../plugin-sandbox/host-api'; function plugin(overrides: Partial = {}): InstalledPlugin { return { @@ -92,3 +92,10 @@ describe('WKD lookup', () => { fetchSpy.mockRestore(); }); }); + +describe('WKD hash', () => { + it('matches the RFC example (Joe.Doe)', async () => { + // draft-koch-openpgp-webkey-service: "Joe.Doe" → iy9q119eutrkn8s1mk4r39qejnbu3n5q + expect(await getWkdHash('Joe.Doe')).toBe('iy9q119eutrkn8s1mk4r39qejnbu3n5q'); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index d437bb2c7..19650ca89 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -31,7 +31,6 @@ import { fileStorage } from '../plugin-storage'; import { generateUUID } from '../utils'; import { AddressBook, ContactCard, Identity } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecurityStore } from '@/stores/account-security-store'; -import { createHash } from 'crypto'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -976,15 +975,16 @@ function zBase32Encode(buffer: Uint8Array): string { * Generates the WKD hash (SHA-1 in z-base-32) from the local-part. * RFC 9292: Lowercase in UTF-8 before hashing. */ -function getWkdHash(localPart: string): string { +export async function getWkdHash(localPart: string): Promise { // NFC normalization + lowercasing const normalizedLocal = localPart.normalize('NFC').toLowerCase(); - - // Explicit TextEncoder usage to guarantee UTF-8 bytes + + // Explicit TextEncoder usage to guarantee UTF-8 bytes. WebCrypto, not + // Node's crypto: importing that pulled a ~330 KB polyfill into the client. const utf8Bytes = new TextEncoder().encode(normalizedLocal); - const sha1Buffer = createHash('sha1').update(utf8Bytes).digest(); - - return zBase32Encode(sha1Buffer); + const sha1 = await crypto.subtle.digest('SHA-1', utf8Bytes); + + return zBase32Encode(new Uint8Array(sha1)); } /** @@ -1031,7 +1031,7 @@ export async function doGetPublicKeyFromWKD(email: string): Promise { if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) { return { status: 'ERROR', error: 'Invalid email address' }; } - const hash = getWkdHash(localPart); + const hash = await getWkdHash(localPart); const urls = [ `https://openpgpkey.${domain}/.well-known/openpgpkey/hu/${hash}?l=${encodeURIComponent(localPart)}`, From f048db7b52b9986af3f292c7de1a4b3a629fd8a8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:33:45 +0200 Subject: [PATCH 021/147] fix: count each plugin hook failure once and keep a plugin that kept failing turned off --- components/plugins/plugin-dialog-host.tsx | 13 +++++++++++ components/settings/plugins-settings.tsx | 2 +- lib/__tests__/plugin-sandbox-loader.test.ts | 24 +++++++++++++++++++++ lib/plugin-loader.ts | 5 ++++- lib/plugin-sandbox/loader.ts | 24 ++++++++++++--------- locales/ar/common.json | 6 ++++-- locales/ca/common.json | 6 ++++-- locales/cs/common.json | 6 ++++-- locales/da/common.json | 6 ++++-- locales/de/common.json | 6 ++++-- locales/en/common.json | 6 ++++-- locales/es/common.json | 6 ++++-- locales/fa/common.json | 6 ++++-- locales/fr/common.json | 6 ++++-- locales/he/common.json | 6 ++++-- locales/hu/common.json | 6 ++++-- locales/it/common.json | 6 ++++-- locales/ja/common.json | 6 ++++-- locales/ko/common.json | 6 ++++-- locales/lv/common.json | 6 ++++-- locales/mn/common.json | 6 ++++-- locales/nb/common.json | 6 ++++-- locales/nl/common.json | 6 ++++-- locales/pl/common.json | 6 ++++-- locales/pt/common.json | 6 ++++-- locales/ro/common.json | 6 ++++-- locales/ru/common.json | 6 ++++-- locales/sk/common.json | 6 ++++-- locales/tr/common.json | 6 ++++-- locales/uk/common.json | 6 ++++-- locales/zh-TW/common.json | 6 ++++-- locales/zh/common.json | 6 ++++-- stores/plugin-store.ts | 23 ++++++++++++++++---- 33 files changed, 183 insertions(+), 70 deletions(-) diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index fcdee26d2..856a74f04 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useId, useMemo, useState, useSyncExternalStore } from import { useTranslations } from 'next-intl'; import { cancelForPlugin, head, resolveDialog, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; import { usePluginStore } from '@/stores/plugin-store'; +import { toast } from '@/stores/toast-store'; import { Button } from '@/components/ui/button'; import { Puzzle, X } from '@/components/icons'; import { PluginIframeSlot } from './plugin-iframe-slot'; @@ -35,6 +36,18 @@ export function PluginDialogHost(): React.JSX.Element | null { const pluginName = usePluginStore((s) => s.plugins.find((p) => p.id === current?.pluginId)?.name); const disablePlugin = usePluginStore((s) => s.disablePlugin); + // Say so when a plugin was turned off for failing; otherwise it just + // stops working with no hint why. + useEffect(() => { + const onDisabled = (e: Event) => { + const id = (e as CustomEvent<{ pluginId: string }>).detail?.pluginId; + const name = usePluginStore.getState().plugins.find((p) => p.id === id)?.name ?? id; + if (id) toast.warning(t('toast_auto_disabled', { name })); + }; + window.addEventListener('plugin:auto-disabled', onDisabled); + return () => window.removeEventListener('plugin:auto-disabled', onDisabled); + }, [t]); + const isPrompt = current?.kind === 'prompt'; const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]); const hasPassword = fields.some((f) => f.type === 'password'); diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 07bc35aa5..f58bb1b44 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -24,7 +24,7 @@ const STATUS_COLORS: Record = { }; /** Errors the store records as codes, so they can be shown translated. */ -const ERROR_CODES = new Set(['user-plugins-off', 'approval-unavailable', 'approval-denied', 'approval-pending']); +const ERROR_CODES = new Set(['user-plugins-off', 'approval-unavailable', 'approval-denied', 'approval-pending', 'auto-disabled']); export function PluginsSettings() { const t = useTranslations('plugins.settings'); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 70e0b3b03..d660a6776 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -219,3 +219,27 @@ describe('setSandboxLocale', () => { expect(mocks.setLocale).toHaveBeenCalledWith('de'); }); }); + +describe('failing hooks', () => { + it('turns a plugin off on its third failure, not its second', async () => { + const { emailHooks, pluginErrorTracker } = await import('../plugin-hooks'); + const { setupSandboxAutoDisable } = await import('../plugin-sandbox/loader'); + const markAutoDisabled = vi.fn(); + setSandboxStoreAccessor({ setPluginStatus: () => {}, markAutoDisabled }); + setupSandboxAutoDisable(); + pluginErrorTracker.reset('managed-plugin'); + mocks.getCode.mockResolvedValue(CODE); + mocks.createBackgroundInstance.mockImplementation(() => ({ + ...fakeBackground(), + initPromise: Promise.resolve({ hooks: ['onEmailOpen'], slots: [], shortcuts: [] }), + invokeHook: vi.fn(() => Promise.reject(new Error('boom'))), + })); + await loadSandboxedPlugin(plugin({ permissions: ['email:read'] })); + + await emailHooks.onEmailOpen.emit({}); + await emailHooks.onEmailOpen.emit({}); + expect(markAutoDisabled).not.toHaveBeenCalled(); + await emailHooks.onEmailOpen.emit({}); + expect(markAutoDisabled).toHaveBeenCalledWith('managed-plugin'); + }); +}); diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index a4b1aa091..747f5f7f3 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -33,7 +33,10 @@ export function exposePluginExternals(): void { // ─── Store accessor (status updates) ────────────────────────── -type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void }; +type StoreAccessor = { + setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void; + markAutoDisabled?: (id: string) => void; +}; export function setPluginStoreAccessor(accessor: StoreAccessor): void { setSandboxStoreAccessor(accessor); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index d7ecf6fe0..b06bfde91 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -39,7 +39,11 @@ const HOOK_BUSES: Record = Object.assign({}, // ─── Store accessor (status updates flow through the existing store) ── -type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void }; +type StoreAccessor = { + setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void; + /** Turn a failing plugin off for good (until the user turns it on again). */ + markAutoDisabled?: (id: string) => void; +}; let storeAccessor: StoreAccessor | null = null; export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor = a; } @@ -187,14 +191,9 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { - try { - return await bg.invokeHook(hookName, args); - } catch (err) { - pluginErrorTracker.record(plugin.id, err); - throw err; - } - }; + // The bus records a failure once; recording it here too made the + // three-strikes breaker trip on the second error. + const proxy = (...args: unknown[]) => bg.invokeHook(hookName, args); hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown)); } @@ -277,7 +276,12 @@ export function setupSandboxAutoDisable(): void { setHookTimeoutListener((pluginId) => cancelPluginDialogs(pluginId)); pluginErrorTracker.setAutoDisableCallback((pluginId) => { unloadSandboxedPlugin(pluginId); - storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors'); + if (storeAccessor?.markAutoDisabled) storeAccessor.markAutoDisabled(pluginId); + else storeAccessor?.setPluginStatus(pluginId, 'error', 'auto-disabled'); + // PluginDialogHost turns this into a toast in the user's language. + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('plugin:auto-disabled', { detail: { pluginId } })); + } }); } diff --git a/locales/ar/common.json b/locales/ar/common.json index d63070241..8ffea83c2 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "أوقف المسؤول الإضافات التي يثبّتها المستخدمون، لذا لا تعمل هذه الإضافة.", "error_approval_unavailable": "ثُبّتت هذه الإضافة قبل أن تصبح الموافقة مطلوبة. أزلها وثبّتها مرة أخرى لإرسالها للموافقة.", "error_approval_denied": "لم يوافق المسؤول على هذه الإضافة.", - "error_approval_pending": "بانتظار موافقة المسؤول على هذه الإضافة." + "error_approval_pending": "بانتظار موافقة المسؤول على هذه الإضافة.", + "error_auto_disabled": "تم إيقافها لأنها فشلت مرارًا. شغّلها مرة أخرى للمحاولة من جديد." }, "consent": { "title": "هل تسمح لـ {name} بذلك؟", @@ -4250,7 +4251,8 @@ "cancel": "إلغاء", "close": "إغلاق", "turn_off": "إيقاف {name}", - "password_note": "هذا النموذج من الإضافة {name} وليس من بريد الويب. لا تدخل كلمة مرور إلا إذا كنت تثق بهذه الإضافة." + "password_note": "هذا النموذج من الإضافة {name} وليس من بريد الويب. لا تدخل كلمة مرور إلا إذا كنت تثق بهذه الإضافة.", + "toast_auto_disabled": "تم إيقاف {name} لأنها فشلت مرارًا." } }, "global_search": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 9b8c72810..5531a18fe 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "L'administrador ha desactivat els connectors instal·lats pels usuaris, així que aquest connector no s'executa.", "error_approval_unavailable": "Aquest connector es va instal·lar abans que calgués l'aprovació. Elimineu-lo i torneu-lo a instal·lar per enviar-lo a aprovar.", "error_approval_denied": "L'administrador no ha aprovat aquest connector.", - "error_approval_pending": "S'espera que l'administrador aprovi aquest connector." + "error_approval_pending": "S'espera que l'administrador aprovi aquest connector.", + "error_auto_disabled": "S'ha desactivat perquè fallava repetidament. Torneu-lo a activar per tornar-ho a provar." }, "consent": { "title": "Voleu permetre que {name} faci això?", @@ -4250,7 +4251,8 @@ "cancel": "Cancel·la", "close": "Tanca", "turn_off": "Desactiva {name}", - "password_note": "Aquest formulari ve del connector {name}, no del correu web. Introduïu una contrasenya només si confieu en aquest connector." + "password_note": "Aquest formulari ve del connector {name}, no del correu web. Introduïu una contrasenya només si confieu en aquest connector.", + "toast_auto_disabled": "{name} s'ha desactivat perquè fallava repetidament." } }, "global_search": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 925b79239..c618351a8 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Váš správce vypnul pluginy instalované uživateli, proto tento plugin neběží.", "error_approval_unavailable": "Tento plugin byl nainstalován dříve, než bylo vyžadováno schválení. Odstraňte ho a nainstalujte znovu, aby se odeslal ke schválení.", "error_approval_denied": "Váš správce tento plugin neschválil.", - "error_approval_pending": "Čeká se, až správce tento plugin schválí." + "error_approval_pending": "Čeká se, až správce tento plugin schválí.", + "error_auto_disabled": "Vypnuto, protože opakovaně selhávalo. Zapněte ho znovu a zkuste to znovu." }, "consent": { "title": "Povolit pluginu {name} tyto akce?", @@ -4250,7 +4251,8 @@ "cancel": "Zrušit", "close": "Zavřít", "turn_off": "Vypnout {name}", - "password_note": "Tento formulář pochází z pluginu {name}, ne z webmailu. Heslo zadejte jen tehdy, když tomuto pluginu důvěřujete." + "password_note": "Tento formulář pochází z pluginu {name}, ne z webmailu. Heslo zadejte jen tehdy, když tomuto pluginu důvěřujete.", + "toast_auto_disabled": "{name} byl vypnut, protože opakovaně selhával." } }, "global_search": { diff --git a/locales/da/common.json b/locales/da/common.json index 77310b3b1..d263b1602 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Din administrator har slået plugins installeret af brugere fra, så dette plugin kører ikke.", "error_approval_unavailable": "Dette plugin blev installeret, før godkendelse var påkrævet. Fjern det, og installer det igen for at sende det til godkendelse.", "error_approval_denied": "Din administrator har ikke godkendt dette plugin.", - "error_approval_pending": "Venter på, at din administrator godkender dette plugin." + "error_approval_pending": "Venter på, at din administrator godkender dette plugin.", + "error_auto_disabled": "Slået fra, fordi det blev ved med at fejle. Slå det til igen for at prøve igen." }, "consent": { "title": "Vil du tillade {name} at gøre dette?", @@ -4250,7 +4251,8 @@ "cancel": "Annuller", "close": "Luk", "turn_off": "Slå {name} fra", - "password_note": "Denne formular kommer fra pluginet {name}, ikke fra webmailen. Indtast kun en adgangskode, hvis du stoler på dette plugin." + "password_note": "Denne formular kommer fra pluginet {name}, ikke fra webmailen. Indtast kun en adgangskode, hvis du stoler på dette plugin.", + "toast_auto_disabled": "{name} blev slået fra, fordi det blev ved med at fejle." } }, "global_search": { diff --git a/locales/de/common.json b/locales/de/common.json index 7f09db870..12eb3a71a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Ihr Administrator hat von Benutzern installierte Plugins ausgeschaltet. Dieses Plugin läuft daher nicht.", "error_approval_unavailable": "Dieses Plugin wurde installiert, bevor eine Freigabe nötig war. Entfernen Sie es und installieren Sie es erneut, um es zur Freigabe vorzulegen.", "error_approval_denied": "Ihr Administrator hat dieses Plugin nicht freigegeben.", - "error_approval_pending": "Wartet darauf, dass Ihr Administrator dieses Plugin freigibt." + "error_approval_pending": "Wartet darauf, dass Ihr Administrator dieses Plugin freigibt.", + "error_auto_disabled": "Ausgeschaltet, weil es wiederholt fehlgeschlagen ist. Schalten Sie es wieder ein, um es erneut zu versuchen." }, "consent": { "title": "{name} Folgendes erlauben?", @@ -4250,7 +4251,8 @@ "cancel": "Abbrechen", "close": "Schließen", "turn_off": "{name} ausschalten", - "password_note": "Dieses Formular stammt vom Plugin {name}, nicht vom Webmail. Geben Sie ein Passwort nur ein, wenn Sie diesem Plugin vertrauen." + "password_note": "Dieses Formular stammt vom Plugin {name}, nicht vom Webmail. Geben Sie ein Passwort nur ein, wenn Sie diesem Plugin vertrauen.", + "toast_auto_disabled": "{name} wurde ausgeschaltet, weil es wiederholt fehlgeschlagen ist." } }, "global_search": { diff --git a/locales/en/common.json b/locales/en/common.json index 5ba9ae751..685016f2d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Your administrator turned off plugins installed by users, so this plugin does not run.", "error_approval_unavailable": "This plugin was installed before approvals were required. Remove it and install it again to send it for approval.", "error_approval_denied": "Your administrator did not approve this plugin.", - "error_approval_pending": "Waiting for your administrator to approve this plugin." + "error_approval_pending": "Waiting for your administrator to approve this plugin.", + "error_auto_disabled": "Turned off because it kept failing. Turn it on again to retry." }, "consent": { "title": "Allow {name} to do this?", @@ -4250,7 +4251,8 @@ "cancel": "Cancel", "close": "Close", "turn_off": "Turn off {name}", - "password_note": "This form comes from the plugin {name}, not from the webmail. Only enter a password if you trust this plugin." + "password_note": "This form comes from the plugin {name}, not from the webmail. Only enter a password if you trust this plugin.", + "toast_auto_disabled": "{name} was turned off because it kept failing." } }, "global_search": { diff --git a/locales/es/common.json b/locales/es/common.json index 3d60e509d..603052683 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Tu administrador desactivó los complementos instalados por usuarios, así que este complemento no se ejecuta.", "error_approval_unavailable": "Este complemento se instaló antes de que se exigiera aprobación. Elimínalo e instálalo de nuevo para enviarlo a aprobación.", "error_approval_denied": "Tu administrador no aprobó este complemento.", - "error_approval_pending": "Esperando a que tu administrador apruebe este complemento." + "error_approval_pending": "Esperando a que tu administrador apruebe este complemento.", + "error_auto_disabled": "Desactivado porque fallaba una y otra vez. Actívalo de nuevo para volver a intentarlo." }, "consent": { "title": "¿Permitir que {name} haga esto?", @@ -4250,7 +4251,8 @@ "cancel": "Cancelar", "close": "Cerrar", "turn_off": "Desactivar {name}", - "password_note": "Este formulario viene del complemento {name}, no del correo web. Introduce una contraseña solo si confías en este complemento." + "password_note": "Este formulario viene del complemento {name}, no del correo web. Introduce una contraseña solo si confías en este complemento.", + "toast_auto_disabled": "{name} se desactivó porque fallaba una y otra vez." } }, "global_search": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 69d5e7445..6726945b8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "مدیر سیستم افزونه‌هایی را که کاربران نصب می‌کنند خاموش کرده است، بنابراین این افزونه اجرا نمی‌شود.", "error_approval_unavailable": "این افزونه پیش از الزامی شدن تأیید نصب شده است. آن را حذف و دوباره نصب کنید تا برای تأیید ارسال شود.", "error_approval_denied": "مدیر سیستم این افزونه را تأیید نکرد.", - "error_approval_pending": "در انتظار تأیید این افزونه توسط مدیر سیستم." + "error_approval_pending": "در انتظار تأیید این افزونه توسط مدیر سیستم.", + "error_auto_disabled": "به‌دلیل خطاهای پی‌درپی خاموش شد. برای تلاش دوباره آن را روشن کنید." }, "consent": { "title": "به {name} اجازه این کار داده شود؟", @@ -4250,7 +4251,8 @@ "cancel": "لغو", "close": "بستن", "turn_off": "خاموش کردن {name}", - "password_note": "این فرم از افزونه {name} است، نه از وب‌میل. فقط اگر به این افزونه اعتماد دارید گذرواژه وارد کنید." + "password_note": "این فرم از افزونه {name} است، نه از وب‌میل. فقط اگر به این افزونه اعتماد دارید گذرواژه وارد کنید.", + "toast_auto_disabled": "{name} به‌دلیل خطاهای پی‌درپی خاموش شد." } }, "global_search": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 8bfe55d18..27ed6886d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Votre administrateur a désactivé les plugins installés par les utilisateurs, ce plugin ne s'exécute donc pas.", "error_approval_unavailable": "Ce plugin a été installé avant que l'approbation soit requise. Supprimez-le et réinstallez-le pour l'envoyer en approbation.", "error_approval_denied": "Votre administrateur n'a pas approuvé ce plugin.", - "error_approval_pending": "En attente de l'approbation de ce plugin par votre administrateur." + "error_approval_pending": "En attente de l'approbation de ce plugin par votre administrateur.", + "error_auto_disabled": "Désactivé parce qu'il échouait sans cesse. Réactivez-le pour réessayer." }, "consent": { "title": "Autoriser {name} à faire ceci ?", @@ -4250,7 +4251,8 @@ "cancel": "Annuler", "close": "Fermer", "turn_off": "Désactiver {name}", - "password_note": "Ce formulaire provient du plugin {name}, pas du webmail. Ne saisissez un mot de passe que si vous faites confiance à ce plugin." + "password_note": "Ce formulaire provient du plugin {name}, pas du webmail. Ne saisissez un mot de passe que si vous faites confiance à ce plugin.", + "toast_auto_disabled": "{name} a été désactivé parce qu'il échouait sans cesse." } }, "global_search": { diff --git a/locales/he/common.json b/locales/he/common.json index 7682a11c1..f1f7fdfcb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4017,7 +4017,8 @@ "error_user_plugins_off": "מנהל המערכת כיבה תוספים שמשתמשים התקינו, ולכן התוסף הזה לא פועל.", "error_approval_unavailable": "התוסף הזה הותקן לפני שנדרש אישור. הסירו אותו והתקינו אותו מחדש כדי לשלוח אותו לאישור.", "error_approval_denied": "מנהל המערכת לא אישר את התוסף הזה.", - "error_approval_pending": "ממתין לאישור התוסף הזה על ידי מנהל המערכת." + "error_approval_pending": "ממתין לאישור התוסף הזה על ידי מנהל המערכת.", + "error_auto_disabled": "כובה כי נכשל שוב ושוב. הפעילו אותו מחדש כדי לנסות שוב." }, "consent": { "title": "לאפשר ל-{name} לעשות זאת?", @@ -4251,7 +4252,8 @@ "cancel": "ביטול", "close": "סגירה", "turn_off": "כיבוי {name}", - "password_note": "הטופס הזה מגיע מהתוסף {name}, לא מהדואר האינטרנטי. הזינו סיסמה רק אם אתם סומכים על התוסף הזה." + "password_note": "הטופס הזה מגיע מהתוסף {name}, לא מהדואר האינטרנטי. הזינו סיסמה רק אם אתם סומכים על התוסף הזה.", + "toast_auto_disabled": "{name} כובה כי נכשל שוב ושוב." } }, "global_search": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 285b6f2b9..b2b779e6f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "A rendszergazda kikapcsolta a felhasználók által telepített bővítményeket, ezért ez a bővítmény nem fut.", "error_approval_unavailable": "Ezt a bővítményt még azelőtt telepítetted, hogy jóváhagyás kellett volna. Távolítsd el, majd telepítsd újra, hogy jóváhagyásra kerüljön.", "error_approval_denied": "A rendszergazda nem hagyta jóvá ezt a bővítményt.", - "error_approval_pending": "Várakozás arra, hogy a rendszergazda jóváhagyja ezt a bővítményt." + "error_approval_pending": "Várakozás arra, hogy a rendszergazda jóváhagyja ezt a bővítményt.", + "error_auto_disabled": "Kikapcsolva, mert többször is hibát okozott. Kapcsolja be újra, ha ismét meg szeretné próbálni." }, "consent": { "title": "{name}: engedélyezed ezeket a műveleteket?", @@ -4250,7 +4251,8 @@ "cancel": "Mégse", "close": "Bezárás", "turn_off": "{name} kikapcsolása", - "password_note": "Ez az űrlap a(z) {name} bővítménytől származik, nem a webmailtől. Csak akkor adjon meg jelszót, ha megbízik ebben a bővítményben." + "password_note": "Ez az űrlap a(z) {name} bővítménytől származik, nem a webmailtől. Csak akkor adjon meg jelszót, ha megbízik ebben a bővítményben.", + "toast_auto_disabled": "{name} ki lett kapcsolva, mert többször is hibát okozott." } }, "global_search": { diff --git a/locales/it/common.json b/locales/it/common.json index 5e6c81183..beb5e13a9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Il tuo amministratore ha disattivato i plugin installati dagli utenti, quindi questo plugin non viene eseguito.", "error_approval_unavailable": "Questo plugin è stato installato prima che fosse richiesta l'approvazione. Rimuovilo e installalo di nuovo per inviarlo in approvazione.", "error_approval_denied": "Il tuo amministratore non ha approvato questo plugin.", - "error_approval_pending": "In attesa che il tuo amministratore approvi questo plugin." + "error_approval_pending": "In attesa che il tuo amministratore approvi questo plugin.", + "error_auto_disabled": "Disattivato perché continuava a non funzionare. Riattivalo per riprovare." }, "consent": { "title": "Consentire a {name} di fare questo?", @@ -4250,7 +4251,8 @@ "cancel": "Annulla", "close": "Chiudi", "turn_off": "Disattiva {name}", - "password_note": "Questo modulo proviene dal plugin {name}, non dalla webmail. Inserisci una password solo se ti fidi di questo plugin." + "password_note": "Questo modulo proviene dal plugin {name}, non dalla webmail. Inserisci una password solo se ti fidi di questo plugin.", + "toast_auto_disabled": "{name} è stato disattivato perché continuava a non funzionare." } }, "global_search": { diff --git a/locales/ja/common.json b/locales/ja/common.json index c325571e3..5288d58b1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "管理者がユーザーによるプラグインを無効にしたため、このプラグインは動作しません。", "error_approval_unavailable": "このプラグインは承認が必要になる前にインストールされました。承認を依頼するには、削除してもう一度インストールしてください。", "error_approval_denied": "管理者はこのプラグインを承認しませんでした。", - "error_approval_pending": "管理者がこのプラグインを承認するのを待っています。" + "error_approval_pending": "管理者がこのプラグインを承認するのを待っています。", + "error_auto_disabled": "繰り返し失敗したためオフになりました。再試行するにはもう一度オンにしてください。" }, "consent": { "title": "{name} にこの操作を許可しますか?", @@ -4250,7 +4251,8 @@ "cancel": "キャンセル", "close": "閉じる", "turn_off": "{name} をオフにする", - "password_note": "このフォームはウェブメールではなく、プラグイン {name} が表示しています。このプラグインを信頼できる場合にのみパスワードを入力してください。" + "password_note": "このフォームはウェブメールではなく、プラグイン {name} が表示しています。このプラグインを信頼できる場合にのみパスワードを入力してください。", + "toast_auto_disabled": "{name} は繰り返し失敗したためオフになりました。" } }, "global_search": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 69c9ef73b..0ce740141 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "관리자가 사용자가 설치한 플러그인을 꺼 두어서 이 플러그인은 실행되지 않아요.", "error_approval_unavailable": "이 플러그인은 승인이 필요해지기 전에 설치되었어요. 승인을 요청하려면 삭제한 뒤 다시 설치하세요.", "error_approval_denied": "관리자가 이 플러그인을 승인하지 않았어요.", - "error_approval_pending": "관리자가 이 플러그인을 승인하기를 기다리고 있어요." + "error_approval_pending": "관리자가 이 플러그인을 승인하기를 기다리고 있어요.", + "error_auto_disabled": "계속 실패해서 꺼졌습니다. 다시 시도하려면 다시 켜세요." }, "consent": { "title": "{name}에 이 권한을 허용할까요?", @@ -4250,7 +4251,8 @@ "cancel": "취소", "close": "닫기", "turn_off": "{name} 끄기", - "password_note": "이 양식은 웹메일이 아니라 플러그인 {name}에서 표시한 것입니다. 이 플러그인을 신뢰할 때만 비밀번호를 입력하세요." + "password_note": "이 양식은 웹메일이 아니라 플러그인 {name}에서 표시한 것입니다. 이 플러그인을 신뢰할 때만 비밀번호를 입력하세요.", + "toast_auto_disabled": "{name}이(가) 계속 실패해서 꺼졌습니다." } }, "global_search": { diff --git a/locales/lv/common.json b/locales/lv/common.json index ed70a2ad2..bc8638beb 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Jūsu administrators ir izslēdzis lietotāju instalētos spraudņus, tāpēc šis spraudnis nedarbojas.", "error_approval_unavailable": "Šis spraudnis tika instalēts, pirms bija vajadzīgs apstiprinājums. Noņemiet to un instalējiet vēlreiz, lai nosūtītu apstiprināšanai.", "error_approval_denied": "Jūsu administrators neapstiprināja šo spraudni.", - "error_approval_pending": "Gaida, kad jūsu administrators apstiprinās šo spraudni." + "error_approval_pending": "Gaida, kad jūsu administrators apstiprinās šo spraudni.", + "error_auto_disabled": "Izslēgts, jo atkārtoti neizdevās. Ieslēdziet to vēlreiz, lai mēģinātu vēlreiz." }, "consent": { "title": "Atļaut spraudnim {name} to darīt?", @@ -4250,7 +4251,8 @@ "cancel": "Atcelt", "close": "Aizvērt", "turn_off": "Izslēgt {name}", - "password_note": "Šī veidlapa nāk no spraudņa {name}, nevis no tīmekļa pasta. Ievadiet paroli tikai tad, ja uzticaties šim spraudnim." + "password_note": "Šī veidlapa nāk no spraudņa {name}, nevis no tīmekļa pasta. Ievadiet paroli tikai tad, ja uzticaties šim spraudnim.", + "toast_auto_disabled": "{name} tika izslēgts, jo atkārtoti neizdevās." } }, "global_search": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 9ba13c1cd..97b68f571 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Таны администратор хэрэглэгчийн суулгасан залгаасуудыг унтраасан тул энэ залгаас ажиллахгүй.", "error_approval_unavailable": "Энэ залгаасыг зөвшөөрөл шаарддаг болохоос өмнө суулгасан. Зөвшөөрөлд илгээхийн тулд устгаад дахин суулгана уу.", "error_approval_denied": "Таны администратор энэ залгаасыг зөвшөөрөөгүй.", - "error_approval_pending": "Таны администратор энэ залгаасыг зөвшөөрөхийг хүлээж байна." + "error_approval_pending": "Таны администратор энэ залгаасыг зөвшөөрөхийг хүлээж байна.", + "error_auto_disabled": "Дахин дахин алдаа гаргасан тул унтраасан. Дахин оролдохын тулд асаана уу." }, "consent": { "title": "{name} залгаасад үүнийг зөвшөөрөх үү?", @@ -4250,7 +4251,8 @@ "cancel": "Цуцлах", "close": "Хаах", "turn_off": "{name}-г унтраах", - "password_note": "Энэ маягт вэб шуудангаас биш, {name} залгаасаас ирсэн. Энэ залгаасанд итгэдэг бол л нууц үгээ оруулна уу." + "password_note": "Энэ маягт вэб шуудангаас биш, {name} залгаасаас ирсэн. Энэ залгаасанд итгэдэг бол л нууц үгээ оруулна уу.", + "toast_auto_disabled": "{name} дахин дахин алдаа гаргасан тул унтраагдлаа." } }, "global_search": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 0f2eb0706..c042d86d2 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Administratoren din har slått av tillegg som brukere har installert, så dette tillegget kjører ikke.", "error_approval_unavailable": "Dette tillegget ble installert før godkjenning ble påkrevd. Fjern det og installer det på nytt for å sende det til godkjenning.", "error_approval_denied": "Administratoren din godkjente ikke dette tillegget.", - "error_approval_pending": "Venter på at administratoren din skal godkjenne dette tillegget." + "error_approval_pending": "Venter på at administratoren din skal godkjenne dette tillegget.", + "error_auto_disabled": "Slått av fordi det feilet gjentatte ganger. Slå det på igjen for å prøve på nytt." }, "consent": { "title": "Vil du la {name} gjøre dette?", @@ -4250,7 +4251,8 @@ "cancel": "Avbryt", "close": "Lukk", "turn_off": "Slå av {name}", - "password_note": "Dette skjemaet kommer fra tillegget {name}, ikke fra webmailen. Skriv bare inn et passord hvis du stoler på dette tillegget." + "password_note": "Dette skjemaet kommer fra tillegget {name}, ikke fra webmailen. Skriv bare inn et passord hvis du stoler på dette tillegget.", + "toast_auto_disabled": "{name} ble slått av fordi det feilet gjentatte ganger." } }, "global_search": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 40200e00b..4b2213065 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Je beheerder heeft plug-ins die door gebruikers zijn geïnstalleerd uitgezet, dus deze plug-in draait niet.", "error_approval_unavailable": "Deze plug-in is geïnstalleerd voordat goedkeuring verplicht was. Verwijder hem en installeer hem opnieuw om hem ter goedkeuring te sturen.", "error_approval_denied": "Je beheerder heeft deze plug-in niet goedgekeurd.", - "error_approval_pending": "Wacht tot je beheerder deze plug-in goedkeurt." + "error_approval_pending": "Wacht tot je beheerder deze plug-in goedkeurt.", + "error_auto_disabled": "Uitgezet omdat het steeds misging. Zet het weer aan om het opnieuw te proberen." }, "consent": { "title": "{name} dit toestaan?", @@ -4250,7 +4251,8 @@ "cancel": "Annuleren", "close": "Sluiten", "turn_off": "{name} uitzetten", - "password_note": "Dit formulier komt van de plug-in {name}, niet van de webmail. Vul alleen een wachtwoord in als je deze plug-in vertrouwt." + "password_note": "Dit formulier komt van de plug-in {name}, niet van de webmail. Vul alleen een wachtwoord in als je deze plug-in vertrouwt.", + "toast_auto_disabled": "{name} is uitgezet omdat het steeds misging." } }, "global_search": { diff --git a/locales/pl/common.json b/locales/pl/common.json index f280e9026..d396f0356 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Administrator wyłączył wtyczki instalowane przez użytkowników, więc ta wtyczka nie działa.", "error_approval_unavailable": "Ta wtyczka została zainstalowana, zanim wprowadzono zatwierdzanie. Usuń ją i zainstaluj ponownie, aby wysłać ją do zatwierdzenia.", "error_approval_denied": "Administrator nie zatwierdził tej wtyczki.", - "error_approval_pending": "Czeka na zatwierdzenie przez administratora." + "error_approval_pending": "Czeka na zatwierdzenie przez administratora.", + "error_auto_disabled": "Wyłączono, bo wielokrotnie zawodziło. Włącz ponownie, aby spróbować jeszcze raz." }, "consent": { "title": "Zezwolić wtyczce {name} na te działania?", @@ -4250,7 +4251,8 @@ "cancel": "Anuluj", "close": "Zamknij", "turn_off": "Wyłącz {name}", - "password_note": "Ten formularz pochodzi z wtyczki {name}, a nie z poczty. Wpisz hasło tylko wtedy, gdy ufasz tej wtyczce." + "password_note": "Ten formularz pochodzi z wtyczki {name}, a nie z poczty. Wpisz hasło tylko wtedy, gdy ufasz tej wtyczce.", + "toast_auto_disabled": "{name} wyłączono, bo wielokrotnie zawodziło." } }, "global_search": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 398581f46..7cd725830 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Seu administrador desativou os plugins instalados por usuários, por isso este plugin não é executado.", "error_approval_unavailable": "Este plugin foi instalado antes de as aprovações serem exigidas. Remova-o e instale-o novamente para enviá-lo para aprovação.", "error_approval_denied": "Seu administrador não aprovou este plugin.", - "error_approval_pending": "Aguardando o seu administrador aprovar este plugin." + "error_approval_pending": "Aguardando o seu administrador aprovar este plugin.", + "error_auto_disabled": "Desativado porque falhava repetidamente. Ative-o de novo para tentar outra vez." }, "consent": { "title": "Permitir que {name} faça isto?", @@ -4250,7 +4251,8 @@ "cancel": "Cancelar", "close": "Fechar", "turn_off": "Desativar {name}", - "password_note": "Este formulário vem do plugin {name}, não do webmail. Só introduza uma palavra-passe se confiar neste plugin." + "password_note": "Este formulário vem do plugin {name}, não do webmail. Só introduza uma palavra-passe se confiar neste plugin.", + "toast_auto_disabled": "{name} foi desativado porque falhava repetidamente." } }, "global_search": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 0f848a590..c71303c6b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Administratorul dvs. a dezactivat pluginurile instalate de utilizatori, așa că acest plugin nu rulează.", "error_approval_unavailable": "Acest plugin a fost instalat înainte ca aprobările să fie obligatorii. Eliminați-l și instalați-l din nou pentru a-l trimite spre aprobare.", "error_approval_denied": "Administratorul dvs. nu a aprobat acest plugin.", - "error_approval_pending": "Se așteaptă ca administratorul să aprobe acest plugin." + "error_approval_pending": "Se așteaptă ca administratorul să aprobe acest plugin.", + "error_auto_disabled": "Dezactivat pentru că a eșuat în mod repetat. Activați-l din nou pentru a reîncerca." }, "consent": { "title": "Permiteți ca {name} să facă acest lucru?", @@ -4250,7 +4251,8 @@ "cancel": "Anulează", "close": "Închide", "turn_off": "Dezactivează {name}", - "password_note": "Acest formular vine de la pluginul {name}, nu de la webmail. Introduceți o parolă doar dacă aveți încredere în acest plugin." + "password_note": "Acest formular vine de la pluginul {name}, nu de la webmail. Introduceți o parolă doar dacă aveți încredere în acest plugin.", + "toast_auto_disabled": "{name} a fost dezactivat pentru că a eșuat în mod repetat." } }, "global_search": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 8dd5a1787..4596ec388 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Администратор отключил плагины, установленные пользователями, поэтому этот плагин не работает.", "error_approval_unavailable": "Этот плагин был установлен до того, как появилось одобрение. Удалите и установите его снова, чтобы отправить на одобрение.", "error_approval_denied": "Администратор не одобрил этот плагин.", - "error_approval_pending": "Ожидается одобрение этого плагина администратором." + "error_approval_pending": "Ожидается одобрение этого плагина администратором.", + "error_auto_disabled": "Отключён, потому что постоянно выдавал ошибки. Включите его снова, чтобы попробовать ещё раз." }, "consent": { "title": "Разрешить плагину {name} это?", @@ -4250,7 +4251,8 @@ "cancel": "Отмена", "close": "Закрыть", "turn_off": "Отключить {name}", - "password_note": "Эта форма от плагина {name}, а не от веб-почты. Вводите пароль, только если доверяете этому плагину." + "password_note": "Эта форма от плагина {name}, а не от веб-почты. Вводите пароль, только если доверяете этому плагину.", + "toast_auto_disabled": "{name} отключён, потому что постоянно выдавал ошибки." } }, "global_search": { diff --git a/locales/sk/common.json b/locales/sk/common.json index b7c7f4ecf..22f4c5530 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Váš správca vypol doplnky inštalované používateľmi, preto tento doplnok nebeží.", "error_approval_unavailable": "Tento doplnok bol nainštalovaný skôr, než sa vyžadovalo schválenie. Odstráňte ho a nainštalujte znova, aby sa odoslal na schválenie.", "error_approval_denied": "Váš správca tento doplnok neschválil.", - "error_approval_pending": "Čaká sa, kým správca tento doplnok schváli." + "error_approval_pending": "Čaká sa, kým správca tento doplnok schváli.", + "error_auto_disabled": "Vypnuté, pretože opakovane zlyhávalo. Zapnite ho znova a skúste to znova." }, "consent": { "title": "Povoliť doplnku {name} tieto akcie?", @@ -4250,7 +4251,8 @@ "cancel": "Zrušiť", "close": "Zavrieť", "turn_off": "Vypnúť {name}", - "password_note": "Tento formulár pochádza z doplnku {name}, nie z webmailu. Heslo zadajte, len ak tomuto doplnku dôverujete." + "password_note": "Tento formulár pochádza z doplnku {name}, nie z webmailu. Heslo zadajte, len ak tomuto doplnku dôverujete.", + "toast_auto_disabled": "{name} bol vypnutý, pretože opakovane zlyhával." } }, "global_search": { diff --git a/locales/tr/common.json b/locales/tr/common.json index c92a95395..acf237629 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Yöneticiniz kullanıcıların yüklediği eklentileri kapattı, bu yüzden bu eklenti çalışmıyor.", "error_approval_unavailable": "Bu eklenti, onay zorunlu olmadan önce yüklendi. Onaya göndermek için kaldırıp yeniden yükleyin.", "error_approval_denied": "Yöneticiniz bu eklentiyi onaylamadı.", - "error_approval_pending": "Bu eklentinin yöneticiniz tarafından onaylanması bekleniyor." + "error_approval_pending": "Bu eklentinin yöneticiniz tarafından onaylanması bekleniyor.", + "error_auto_disabled": "Sürekli hata verdiği için kapatıldı. Yeniden denemek için tekrar açın." }, "consent": { "title": "{name} eklentisine bu izin verilsin mi?", @@ -4250,7 +4251,8 @@ "cancel": "İptal", "close": "Kapat", "turn_off": "{name} eklentisini kapat", - "password_note": "Bu form web postasından değil, {name} eklentisinden geliyor. Yalnızca bu eklentiye güveniyorsanız parola girin." + "password_note": "Bu form web postasından değil, {name} eklentisinden geliyor. Yalnızca bu eklentiye güveniyorsanız parola girin.", + "toast_auto_disabled": "{name} sürekli hata verdiği için kapatıldı." } }, "global_search": { diff --git a/locales/uk/common.json b/locales/uk/common.json index a8ce891cb..8ce2a0b12 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "Ваш адміністратор вимкнув плагіни, встановлені користувачами, тому цей плагін не працює.", "error_approval_unavailable": "Цей плагін встановлено ще до того, як з'явилося схвалення. Видаліть і встановіть його знову, щоб надіслати на схвалення.", "error_approval_denied": "Ваш адміністратор не схвалив цей плагін.", - "error_approval_pending": "Очікується, що ваш адміністратор схвалить цей плагін." + "error_approval_pending": "Очікується, що ваш адміністратор схвалить цей плагін.", + "error_auto_disabled": "Вимкнено, бо постійно виникали помилки. Увімкніть його знову, щоб спробувати ще раз." }, "consent": { "title": "Дозволити плагіну {name} це?", @@ -4250,7 +4251,8 @@ "cancel": "Скасувати", "close": "Закрити", "turn_off": "Вимкнути {name}", - "password_note": "Ця форма від плагіна {name}, а не від вебпошти. Вводьте пароль, лише якщо довіряєте цьому плагіну." + "password_note": "Ця форма від плагіна {name}, а не від вебпошти. Вводьте пароль, лише якщо довіряєте цьому плагіну.", + "toast_auto_disabled": "{name} вимкнено, бо постійно виникали помилки." } }, "global_search": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index c62928181..1415806e0 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "管理員已關閉使用者安裝的外掛程式,因此此外掛程式不會執行。", "error_approval_unavailable": "此外掛程式是在需要核准之前安裝的。請移除後重新安裝,以送交核准。", "error_approval_denied": "管理員未核准此外掛程式。", - "error_approval_pending": "正在等待管理員核准此外掛程式。" + "error_approval_pending": "正在等待管理員核准此外掛程式。", + "error_auto_disabled": "由於反覆出錯已關閉。重新開啟即可再試一次。" }, "consent": { "title": "要允許 {name} 執行這些動作嗎?", @@ -4250,7 +4251,8 @@ "cancel": "取消", "close": "關閉", "turn_off": "關閉 {name}", - "password_note": "此表單來自外掛程式 {name},而不是網頁郵件。只有在您信任此外掛程式時才輸入密碼。" + "password_note": "此表單來自外掛程式 {name},而不是網頁郵件。只有在您信任此外掛程式時才輸入密碼。", + "toast_auto_disabled": "{name} 因反覆出錯已被關閉。" } }, "global_search": { diff --git a/locales/zh/common.json b/locales/zh/common.json index eb74276ae..10342d03d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4016,7 +4016,8 @@ "error_user_plugins_off": "管理员已关闭用户安装的插件,因此此插件不会运行。", "error_approval_unavailable": "此插件是在需要审批之前安装的。请移除后重新安装,以提交审批。", "error_approval_denied": "管理员未批准此插件。", - "error_approval_pending": "正在等待管理员批准此插件。" + "error_approval_pending": "正在等待管理员批准此插件。", + "error_auto_disabled": "由于反复出错已关闭。重新开启即可再试一次。" }, "consent": { "title": "允许 {name} 执行这些操作?", @@ -4250,7 +4251,8 @@ "cancel": "取消", "close": "关闭", "turn_off": "关闭 {name}", - "password_note": "此表单来自插件 {name},而不是网页邮箱。只有在您信任此插件时才输入密码。" + "password_note": "此表单来自插件 {name},而不是网页邮箱。只有在您信任此插件时才输入密码。", + "toast_auto_disabled": "{name} 因反复出错已被关闭。" } }, "global_search": { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 3917a5358..fede3c8ee 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -207,7 +207,7 @@ export const usePluginStore = create()( } // Ensure bridge is wired before loading (may not have run initializePlugins yet) - setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus }); + wireLoader(get, set); set(state => ({ plugins: state.plugins.map(p => @@ -283,9 +283,7 @@ export const usePluginStore = create()( } // Wire up bridges - setPluginStoreAccessor({ - setPluginStatus: get().setPluginStatus, - }); + wireLoader(get, set); setupAutoDisable(); // Keep the sandbox locale in step with the app locale. Set it // synchronously *before* activation so background instances get the @@ -355,6 +353,23 @@ export const usePluginStore = create()( ) ); +/** Give the loader its way back into the store. */ +function wireLoader( + get: () => PluginStoreState, + set: (partial: Partial | ((state: PluginStoreState) => Partial)) => void, +): void { + setPluginStoreAccessor({ + setPluginStatus: get().setPluginStatus, + markAutoDisabled: (id) => set(state => ({ + plugins: state.plugins.map(p => (p.id === id + // enabled: false is persisted, so a reload doesn't start the + // failing plugin again behind the user's back. + ? { ...p, enabled: false, status: 'error' as PluginStatus, error: 'auto-disabled' } + : p)), + })), + }); +} + // ─── Server Plugin Sync ────────────────────────────────────── interface ServerPluginInfo { From cef2438c3356fa2b2a0aee64d71fb05cfd72e22d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:34:31 +0200 Subject: [PATCH 022/147] fix: let untrusted plugins upload attachments for the composer again --- lib/__tests__/plugin-api-gates.test.ts | 11 ++++++++--- lib/plugin-sandbox/host-api.ts | 6 +++--- lib/plugin-sandbox/runtime.tsx | 1 + 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts index 27a895eb6..d33b80ad1 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -306,11 +306,16 @@ describe('native keyword extension API', () => { }); }); -describe('privileged-only methods', () => { - it('refuses jmap.uploadBlob to an untrusted plugin even with email:blob-write', async () => { +describe('jmap.uploadBlob', () => { + it('is open to an untrusted plugin with email:blob-write, for composer attachment sources', async () => { const p = plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] }); expect(await gateError(p, 'jmap.uploadBlob', [new Uint8Array(1), 'a.bin', 'application/octet-stream'])) - .toMatch(/requires the privileged plugin tier/); + .not.toMatch(GATE_FAILURE); + }); + + it('still needs email:blob-write', async () => { + expect(await gateError(plugin(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.bin', 'x/y'])) + .toMatch(/lacks permission "email:blob-write"/); }); }); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 19650ca89..b421f94f6 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -40,9 +40,9 @@ import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecuri */ const PRIVILEGED_ONLY_METHODS = new Set([ 'jmap.fetchBlob', - // Puts arbitrary bytes on the mail server. Untrusted plugins attach files - // through the composer and draft APIs instead, which bound and label them. - 'jmap.uploadBlob', + // NOTE: jmap.uploadBlob is deliberately NOT here. The composer's + // `composer-attachment-source` slot is built for untrusted plugins that + // upload a file (from cloud storage, say) and hand the blob id to onAttach. 'jmap.sendRaw', 'jmap.submitRaw', 'jmap.importRaw', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 20f02b1ef..ffda72e4f 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -328,6 +328,7 @@ function buildPluginApi(manifest: PluginManifest) { /** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */ fetchBlob: (blobId: string, opts?: { name?: string; type?: string, rangeHeader?: number }) => callApi('jmap.fetchBlob', [blobId, opts]) as Promise, + /** Upload bytes to the mail server (email:blob-write); pass the blob id to a composer's onAttach. */ uploadBlob: (content: Uint8Array, name: string, type: string) => callApi('jmap.uploadBlob', [content, name, type]) as Promise<{ blobId: string; size: number; type: string; }>, /** Submit a fully-formed raw RFC822 message (already signed/encrypted). */ From 2adb1e2b12bec5dfc9c56cc0dbc13c3d26a23306 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:53:22 +0200 Subject: [PATCH 023/147] fix: size toolbar plugin slots to their content and give composer plugin buttons their own row --- ...composer-plugin-attachment-source.test.tsx | 4 ++ components/email/email-composer.tsx | 38 ++++++++++------ components/plugins/plugin-iframe-slot.tsx | 18 +++++++- components/plugins/plugin-slot.tsx | 8 +++- .../plugin-host-bridge-origin.test.ts | 43 ++++++++++++++++++- lib/plugin-sandbox/host-bridge.ts | 29 ++++++++++--- lib/plugin-sandbox/protocol.ts | 2 + lib/plugin-sandbox/runtime.tsx | 29 +++++++++---- lib/plugin-sandbox/slot-layout.ts | 40 +++++++++++++++++ 9 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 lib/plugin-sandbox/slot-layout.ts diff --git a/components/email/__tests__/composer-plugin-attachment-source.test.tsx b/components/email/__tests__/composer-plugin-attachment-source.test.tsx index 55a8cb17d..c95edad1b 100644 --- a/components/email/__tests__/composer-plugin-attachment-source.test.tsx +++ b/components/email/__tests__/composer-plugin-attachment-source.test.tsx @@ -15,6 +15,10 @@ vi.mock('@/components/email/rich-text-editor', () => ({ // callback the host passed through extraProps - exactly what a real plugin // does after it has uploaded a picked file to JMAP itself (api.jmap.uploadBlob). // Every other slot renders nothing, matching the rest of the test suite. +// The composer only renders its plugin row when a plugin offers a slot. +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: (slot: string) => (slot === 'composer-attachment-source' ? [{ pluginId: 'p', order: 100 }] : []), +})); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: ({ name, extraProps }: { name: string; extraProps?: Record }) => { if (name !== 'composer-attachment-source') return null; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 664a93611..e91bb0cd1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -62,6 +62,7 @@ import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@ import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePickerDialog } from "@/components/files/file-picker-dialog"; @@ -1946,6 +1947,11 @@ export function EmailComposer({ }, ]); }, []); + // Stable props, so a re-render of the composer doesn't post them to the + // plugin's iframe again. + const pluginAttachmentSourceProps = useMemo(() => ({ onAttach: handlePluginAttachmentAdd }), [handlePluginAttachmentAdd]); + const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); + const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source'); // Files from the Files app are already blobs on the server, so they are // attached by blobId with no download or upload (#1179). Stalwart resolves a @@ -3637,6 +3643,24 @@ export function EmailComposer({
)} + {/* Plugin buttons get their own row: the composer is often too narrow + for them next to Attach, Templates, Options, Discard and Send, and + squeezing them in pushed Send out of the window. */} + {(pluginToolbarOffers.length > 0 || pluginAttachmentSourceOffers.length > 0) && ( +
+ + {/* Lets a plugin offer an alternative attachment source (an + external file browser, cloud storage picker, etc). The plugin + calls `onAttach` once it has uploaded the chosen file to JMAP + itself (api.jmap.uploadBlob) and just wants it added to this + draft. */} + +
+ )} + {/* Bottom toolbar. In Mountain View's window both halves fold into one row (see [data-mv-compose-footer] in globals.css): Send first, the tools after it, and Discard as a bin at the far end. */} @@ -3648,7 +3672,7 @@ export function EmailComposer({ )} > {/* Left side actions */} -
+
} - - {/* Lets a plugin offer an alternative attachment source (an - external file browser, cloud storage picker, etc). The plugin - calls `onAttach` once it has uploaded the chosen file to JMAP - itself (api.jmap.uploadBlob) and just wants it added to this - draft. */} -
{/* Right side - Discard + Send (desktop) */} -
+
- +
From 3bfc0e69d05bfac1683458545fcebb115447e187 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:58:47 +0200 Subject: [PATCH 025/147] fix: check attachments a plugin adds to the composer and keep callbacks out of shouldShow --- ...composer-plugin-attachment-source.test.tsx | 33 +++++++++++-- components/email/email-composer.tsx | 48 ++++++++++++++----- .../plugin-host-bridge-origin.test.ts | 13 +++++ lib/plugin-sandbox/host-bridge.ts | 18 ++++++- 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/components/email/__tests__/composer-plugin-attachment-source.test.tsx b/components/email/__tests__/composer-plugin-attachment-source.test.tsx index c95edad1b..03af74276 100644 --- a/components/email/__tests__/composer-plugin-attachment-source.test.tsx +++ b/components/email/__tests__/composer-plugin-attachment-source.test.tsx @@ -19,6 +19,10 @@ vi.mock('@/components/email/rich-text-editor', () => ({ vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: (slot: string) => (slot === 'composer-attachment-source' ? [{ pluginId: 'p', order: 100 }] : []), })); +const attachState = vi.hoisted(() => ({ + next: { blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 } as Record, + allow: true, +})); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: ({ name, extraProps }: { name: string; extraProps?: Record }) => { if (name !== 'composer-attachment-source') return null; @@ -30,8 +34,7 @@ vi.mock('@/components/plugins/plugin-slot', () => ({ { type: 'button', 'data-testid': 'fake-cloud-storage-plugin', - onClick: () => - onAttach?.({ blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 }), + onClick: () => onAttach?.(attachState.next as never), }, 'Attach from cloud storage', ); @@ -169,6 +172,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + onBeforeAttachmentUpload: { intercept: async () => attachState.allow }, }, contactHooks: { search: { call: async () => [] }, @@ -212,6 +216,29 @@ describe('composer-attachment-source plugin slot', () => { afterEach(() => { useAuthStore.setState({ client: null }); vi.clearAllMocks(); + attachState.next = { blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 }; + attachState.allow = true; + }); + + it('ignores something from a plugin that is not an attachment', async () => { + attachState.next = { blobId: 'not a blob id', name: 'evil.jpg', type: 'image/jpeg', size: 1 }; + render(); + + fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin')); + + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText('evil.jpg')).not.toBeInTheDocument(); + }); + + it('applies the same attachment checks as a local file', async () => { + // A plugin's onBeforeAttachmentUpload guard used to be skipped here. + attachState.allow = false; + render(); + + fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin')); + + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText('vacation.jpg')).not.toBeInTheDocument(); }); it('adds a plugin-supplied blob as an attachment chip when onAttach is called', async () => { @@ -235,7 +262,7 @@ describe('composer-attachment-source plugin slot', () => { render(); fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin')); - expect(screen.getByText('vacation.jpg')).toBeInTheDocument(); + expect(await screen.findByText('vacation.jpg')).toBeInTheDocument(); // Fake timers only from here on - the autosave debounce below needs // vi.advanceTimersByTimeAsync, but findBy*'s own internal polling (not diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index e91bb0cd1..cb045cb38 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -324,6 +324,22 @@ function getDefaultScheduleValue(): string { // the body instead (#1114). const PINNED_FIELDS_MIN_BODY_HEIGHT = 200; +/** + * An attachment a plugin uploaded itself, as the composer stores it, or null + * when the plugin sent something that isn't one. + */ +export function normalizePluginAttachment(raw: unknown): { name: string; type: string; size: number; blobId: string } | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (typeof r.blobId !== 'string' || !/^[A-Za-z0-9_-]{1,255}$/.test(r.blobId)) return null; + if (typeof r.size !== 'number' || !Number.isFinite(r.size) || r.size < 0) return null; + // eslint-disable-next-line no-control-regex -- removing control characters is the point + const name = typeof r.name === 'string' ? r.name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) : ''; + if (!name) return null; + const type = typeof r.type === 'string' && /^[\w.+-]+\/[\w.+-]+$/.test(r.type) ? r.type : 'application/octet-stream'; + return { name, type, size: Math.floor(r.size), blobId: r.blobId }; +} + export function EmailComposer({ onSend, onScheduledSendCreated, @@ -1936,17 +1952,27 @@ export function EmailComposer({ // offers "attach from there". No `file` is set, matching the existing // draft-part hydration path above (a blobId-only attachment is already a // supported shape, just never previously reachable from a plugin). - const handlePluginAttachmentAdd = useCallback((upload: PluginAttachmentUpload) => { - setAttachments(prev => [ - ...prev, - { - name: upload.name, - type: upload.type, - size: upload.size, - blobId: upload.blobId, - }, - ]); - }, []); + const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload): Promise => { + // The values come from a plugin: check them, and run the same intercept + // and size limit every other way of attaching a file goes through. + const upload = normalizePluginAttachment(raw); + if (!upload) { + debug.warn('email', 'Refused a malformed attachment from a plugin', raw); + return false; + } + const ok = await emailHooks.onBeforeAttachmentUpload.intercept({ + name: upload.name, type: upload.type, size: upload.size, + }); + if (!ok) return false; + const maxTotal = composerClient?.getMaxSizeAttachmentsPerEmail?.() ?? 0; + const total = attachmentsRef.current.reduce((sum, att) => sum + (att.size || 0), 0); + if (maxTotal > 0 && total + upload.size > maxTotal) { + toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) })); + return false; + } + setAttachments(prev => [...prev, upload]); + return true; + }, [composerClient, t]); // Stable props, so a re-render of the composer doesn't post them to the // plugin's iframe again. const pluginAttachmentSourceProps = useMemo(() => ({ onAttach: handlePluginAttachmentAdd }), [handlePluginAttachmentAdd]); diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index 126e247f9..5dd2d1598 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -111,3 +111,16 @@ describe('slot sizing', () => { expect(inst.iframe.style.width).toBe('100%'); }); }); + +describe('shouldShow context', () => { + it('leaves host callbacks out instead of failing to post them', async () => { + const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }); + instances.push(inst); + const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage'); + + void inst.evaluateShouldShow('composer-attachment-source', { onAttach: () => {}, nested: { cb: () => {}, n: 1 } }); + + const msg = sent.mock.calls.map(([m]) => m as { type: string; context?: unknown }).find((m) => m.type === 'slot-should-show'); + expect(msg?.context).toEqual({ nested: { n: 1 } }); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 721fcf342..ac2c9bcad 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -327,7 +327,10 @@ export class SandboxInstance { const p = new Promise((resolve) => { this.pendingShouldShow.set(id, resolve); }); - this.send({ type: 'slot-should-show', id, slot, context }); + // shouldShow only decides; it gets the data, not the host callbacks. + // Functions can't be posted at all - sending them threw and took the + // composer or event form down with it. + this.send({ type: 'slot-should-show', id, slot, context: withoutFunctions(context) }); return p; } @@ -372,6 +375,19 @@ export class SandboxInstance { } } +/** Copy of a props object with every function left out. */ +export function withoutFunctions(value: unknown, depth = 0): unknown { + if (depth > 6 || typeof value === 'function') return undefined; + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((v) => withoutFunctions(v, depth + 1)); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === 'function') continue; + out[k] = withoutFunctions(v, depth + 1); + } + return out; +} + function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } From fe4f809092fc95b3c8d59592feadcae8e24852da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:00:46 +0200 Subject: [PATCH 026/147] fix: keep a plugin side panel from squeezing the message too narrow to read --- .../viewer-plugin-panel-width.test.tsx | 78 +++++++++++++++++++ components/email/email-viewer.tsx | 41 ++++++++-- locales/ar/common.json | 4 +- locales/ca/common.json | 4 +- locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 22 +++--- locales/en/common.json | 22 +++--- locales/es/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/he/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/mn/common.json | 4 +- locales/nb/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/sk/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/zh-TW/common.json | 4 +- locales/zh/common.json | 4 +- 29 files changed, 212 insertions(+), 51 deletions(-) create mode 100644 components/email/__tests__/viewer-plugin-panel-width.test.tsx diff --git a/components/email/__tests__/viewer-plugin-panel-width.test.tsx b/components/email/__tests__/viewer-plugin-panel-width.test.tsx new file mode 100644 index 000000000..a16703c47 --- /dev/null +++ b/components/email/__tests__/viewer-plugin-panel-width.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// With a plugin panel open at 280 px next to a 300 px message (three panes +// on a laptop), the subject broke after every few letters. The panel now +// starts collapsed when it would leave the message under 480 px. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: ({ name }: { name: string }) => React.createElement('div', { 'data-testid': `slot-${name}` }), +})); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'notes', order: 100 }] : []), +})); + +let reportWidth = 0; +const observers: Array<(entries: Array<{ contentRect: { width: number } }>) => void> = []; +class FakeResizeObserver { + constructor(private cb: (entries: Array<{ contentRect: { width: number } }>) => void) { observers.push(cb); } + observe() { this.cb([{ contentRect: { width: reportWidth } }]); } + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', FakeResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', + preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +} as unknown as Email; + +function renderViewer() { + return render(); +} + +beforeEach(() => { + localStorage.clear(); + observers.length = 0; +}); + +describe('plugin detail panel and narrow messages', () => { + it('starts collapsed when it would leave the message too narrow', () => { + reportWidth = 640; + renderViewer(); + + expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument(); + expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument(); + }); + + it('opens anyway when the user asks', () => { + reportWidth = 640; + renderViewer(); + + act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); }); + + expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument(); + }); + + it('is open when there is room', () => { + reportWidth = 1100; + renderViewer(); + + expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 030af2e87..8fc8e9118 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -961,6 +961,9 @@ function DetailsCategorySlot({ email, category }: { email: EmailReadView | null; return ; } +/** Narrowest the message may get next to a plugin panel before the panel collapses. */ +const MIN_READER_WIDTH = 480; + function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { return (
@@ -1422,6 +1425,25 @@ export function EmailViewer({ } catch { return 280; } }); const detailSidebarWidthRef = useRef(detailSidebarWidth); + // The panel takes its width from the message. When that would leave the + // message too narrow to read (three panes on a laptop), the panel starts + // collapsed; opening it anyway is still the user's call. + const viewerRootRef = useRef(null); + const [viewerWidth, setViewerWidth] = useState(0); + const [detailSidebarForcedOpen, setDetailSidebarForcedOpen] = useState(false); + useEffect(() => { + if (!hasDetailSidebar || typeof ResizeObserver === 'undefined') return; + const el = viewerRootRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const width = Math.round(entries[0]?.contentRect.width ?? 0); + setViewerWidth(width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [hasDetailSidebar]); + const readerTooNarrow = viewerWidth > 0 && viewerWidth - detailSidebarWidth < MIN_READER_WIDTH; + const sidebarCollapsed = detailSidebarCollapsed || (readerTooNarrow && !detailSidebarForcedOpen); useEffect(() => { try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ } @@ -3993,6 +4015,7 @@ export function EmailViewer({ return (
@@ -6073,20 +6096,23 @@ export function EmailViewer({ {hasDetailSidebar && !isMobile && ( <> {/* Collapse toggle when sidebar is collapsed */} - {detailSidebarCollapsed && ( + {sidebarCollapsed && (
)} - {!detailSidebarCollapsed && ( + {!sidebarCollapsed && ( <> { detailSidebarWidthRef.current = detailSidebarWidth; }} @@ -6105,9 +6131,12 @@ export function EmailViewer({ diff --git a/locales/ar/common.json b/locales/ar/common.json index 8ffea83c2..24f3f9268 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -620,7 +620,9 @@ "copied": "تم نسخ الرمز", "copy_failed": "تعذّر نسخ الرمز" }, - "mv_position": "{index} من {total}" + "mv_position": "{index} من {total}", + "plugin_panel_show": "إظهار لوحة الإضافة", + "plugin_panel_hide": "إخفاء لوحة الإضافة" }, "email_composer": { "minimize": "تصغير", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5531a18fe..d9ea2a1b8 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -620,7 +620,9 @@ "copied": "Codi copiat", "copy_failed": "No s'ha pogut copiar el codi" }, - "mv_position": "{index} de {total}" + "mv_position": "{index} de {total}", + "plugin_panel_show": "Mostra el tauler del connector", + "plugin_panel_hide": "Amaga el tauler del connector" }, "email_composer": { "minimize": "Minimitza", diff --git a/locales/cs/common.json b/locales/cs/common.json index c618351a8..aec9ada4e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -620,7 +620,9 @@ "copied": "Kód zkopírován", "copy_failed": "Kód se nepodařilo zkopírovat" }, - "mv_position": "{index} z {total}" + "mv_position": "{index} z {total}", + "plugin_panel_show": "Zobrazit panel pluginu", + "plugin_panel_hide": "Skrýt panel pluginu" }, "email_composer": { "minimize": "Minimalizovat", diff --git a/locales/da/common.json b/locales/da/common.json index d263b1602..e099ca36a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -620,7 +620,9 @@ "copied": "Kode kopieret", "copy_failed": "Koden kunne ikke kopieres" }, - "mv_position": "{index} af {total}" + "mv_position": "{index} af {total}", + "plugin_panel_show": "Vis pluginpanel", + "plugin_panel_hide": "Skjul pluginpanel" }, "email_composer": { "minimize": "Minimer", diff --git a/locales/de/common.json b/locales/de/common.json index 12eb3a71a..2ba2ed0de 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -359,15 +359,15 @@ "external_content_warning": "Bilder und externe Inhalte wurden blockiert", "load_external_content": "Bilder laden", "trust_sender": "Diesem Absender immer vertrauen", - "sender_check": { - "unverified_label": "Absender nicht bestätigt", - "failed_label": "Absenderprüfung fehlgeschlagen", - "unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.", - "unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.", - "failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.", - "failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.", - "caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten." - }, + "sender_check": { + "unverified_label": "Absender nicht bestätigt", + "failed_label": "Absenderprüfung fehlgeschlagen", + "unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.", + "unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.", + "failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.", + "failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.", + "caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten." + }, "back_to_list": "Zurück zur Liste", "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", @@ -620,7 +620,9 @@ "copied": "Code kopiert", "copy_failed": "Code konnte nicht kopiert werden" }, - "mv_position": "{index} von {total}" + "mv_position": "{index} von {total}", + "plugin_panel_show": "Plugin-Bereich einblenden", + "plugin_panel_hide": "Plugin-Bereich ausblenden" }, "email_composer": { "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index 685016f2d..942d372a4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -359,15 +359,15 @@ "external_content_warning": "Images and external content have been blocked", "load_external_content": "Load images", "trust_sender": "Always trust this sender", - "sender_check": { - "unverified_label": "Unverified sender", - "failed_label": "Sender check failed", - "unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.", - "unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.", - "failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.", - "failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", - "caution": "Be careful with links, attachments and requests for passwords or payment details." - }, + "sender_check": { + "unverified_label": "Unverified sender", + "failed_label": "Sender check failed", + "unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.", + "unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.", + "failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.", + "failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", + "caution": "Be careful with links, attachments and requests for passwords or payment details." + }, "back_to_list": "Back to list", "view_contact": "View contact", "message_details": "Message Details", @@ -620,7 +620,9 @@ "copied": "Code copied", "copy_failed": "Could not copy the code" }, - "mv_position": "{index} of {total}" + "mv_position": "{index} of {total}", + "plugin_panel_show": "Show plugin panel", + "plugin_panel_hide": "Hide plugin panel" }, "email_composer": { "minimize": "Minimize", diff --git a/locales/es/common.json b/locales/es/common.json index 603052683..4a7136e37 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -620,7 +620,9 @@ "copied": "Código copiado", "copy_failed": "No se pudo copiar el código" }, - "mv_position": "{index} de {total}" + "mv_position": "{index} de {total}", + "plugin_panel_show": "Mostrar el panel del complemento", + "plugin_panel_hide": "Ocultar el panel del complemento" }, "email_composer": { "minimize": "Minimizar", diff --git a/locales/fa/common.json b/locales/fa/common.json index 6726945b8..2343dac11 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -620,7 +620,9 @@ "copied": "کد کپی شد", "copy_failed": "کپی کد ممکن نشد" }, - "mv_position": "{index} از {total}" + "mv_position": "{index} از {total}", + "plugin_panel_show": "نمایش پنل افزونه", + "plugin_panel_hide": "پنهان کردن پنل افزونه" }, "email_composer": { "minimize": "کوچک کردن", diff --git a/locales/fr/common.json b/locales/fr/common.json index 27ed6886d..3527665e8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -620,7 +620,9 @@ "copied": "Code copié", "copy_failed": "Impossible de copier le code" }, - "mv_position": "{index} sur {total}" + "mv_position": "{index} sur {total}", + "plugin_panel_show": "Afficher le panneau du plugin", + "plugin_panel_hide": "Masquer le panneau du plugin" }, "email_composer": { "minimize": "Réduire", diff --git a/locales/he/common.json b/locales/he/common.json index f1f7fdfcb..10b638bb2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -585,7 +585,9 @@ "copied": "הקוד הועתק", "copy_failed": "לא ניתן להעתיק את הקוד" }, - "mv_position": "{index} מתוך {total}" + "mv_position": "{index} מתוך {total}", + "plugin_panel_show": "הצגת חלונית התוסף", + "plugin_panel_hide": "הסתרת חלונית התוסף" }, "email_composer": { "minimize": "מזעור", diff --git a/locales/hu/common.json b/locales/hu/common.json index b2b779e6f..550c4292e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -620,7 +620,9 @@ "copied": "Kód másolva", "copy_failed": "Nem sikerült másolni a kódot" }, - "mv_position": "{index} / {total}" + "mv_position": "{index} / {total}", + "plugin_panel_show": "Bővítménypanel megjelenítése", + "plugin_panel_hide": "Bővítménypanel elrejtése" }, "email_composer": { "minimize": "Kis méret", diff --git a/locales/it/common.json b/locales/it/common.json index beb5e13a9..a738930ef 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -620,7 +620,9 @@ "copied": "Codice copiato", "copy_failed": "Impossibile copiare il codice" }, - "mv_position": "{index} di {total}" + "mv_position": "{index} di {total}", + "plugin_panel_show": "Mostra il pannello del plugin", + "plugin_panel_hide": "Nascondi il pannello del plugin" }, "email_composer": { "minimize": "Riduci a icona", diff --git a/locales/ja/common.json b/locales/ja/common.json index 5288d58b1..b97e9101e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -620,7 +620,9 @@ "copied": "コードをコピーしました", "copy_failed": "コードをコピーできませんでした" }, - "mv_position": "{index} / {total}" + "mv_position": "{index} / {total}", + "plugin_panel_show": "プラグインパネルを表示", + "plugin_panel_hide": "プラグインパネルを隠す" }, "email_composer": { "minimize": "最小化", diff --git a/locales/ko/common.json b/locales/ko/common.json index 0ce740141..195cf5797 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -620,7 +620,9 @@ "copied": "코드를 복사했습니다", "copy_failed": "코드를 복사할 수 없습니다" }, - "mv_position": "{index} / {total}" + "mv_position": "{index} / {total}", + "plugin_panel_show": "플러그인 패널 표시", + "plugin_panel_hide": "플러그인 패널 숨기기" }, "email_composer": { "minimize": "최소화", diff --git a/locales/lv/common.json b/locales/lv/common.json index bc8638beb..cf298dfeb 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -620,7 +620,9 @@ "copied": "Kods nokopēts", "copy_failed": "Neizdevās nokopēt kodu" }, - "mv_position": "{index} no {total}" + "mv_position": "{index} no {total}", + "plugin_panel_show": "Rādīt spraudņa paneli", + "plugin_panel_hide": "Slēpt spraudņa paneli" }, "email_composer": { "minimize": "Minimizēt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 97b68f571..21ba2eed0 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -620,7 +620,9 @@ "copied": "Код хуулагдлаа", "copy_failed": "Кодыг хуулж чадсангүй" }, - "mv_position": "{total}-с {index}" + "mv_position": "{total}-с {index}", + "plugin_panel_show": "Залгаасын самбарыг харуулах", + "plugin_panel_hide": "Залгаасын самбарыг нуух" }, "email_composer": { "minimize": "Багасгах", diff --git a/locales/nb/common.json b/locales/nb/common.json index c042d86d2..0ea3afcd0 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -620,7 +620,9 @@ "copied": "Kode kopiert", "copy_failed": "Kunne ikke kopiere koden" }, - "mv_position": "{index} av {total}" + "mv_position": "{index} av {total}", + "plugin_panel_show": "Vis tilleggspanel", + "plugin_panel_hide": "Skjul tilleggspanel" }, "email_composer": { "minimize": "Minimer", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4b2213065..12acefe93 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -620,7 +620,9 @@ "copied": "Code gekopieerd", "copy_failed": "Kan de code niet kopiëren" }, - "mv_position": "{index} van {total}" + "mv_position": "{index} van {total}", + "plugin_panel_show": "Plug-inpaneel tonen", + "plugin_panel_hide": "Plug-inpaneel verbergen" }, "email_composer": { "minimize": "Minimaliseren", diff --git a/locales/pl/common.json b/locales/pl/common.json index d396f0356..c0d941186 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -620,7 +620,9 @@ "copied": "Skopiowano kod", "copy_failed": "Nie udało się skopiować kodu" }, - "mv_position": "{index} z {total}" + "mv_position": "{index} z {total}", + "plugin_panel_show": "Pokaż panel wtyczki", + "plugin_panel_hide": "Ukryj panel wtyczki" }, "email_composer": { "minimize": "Minimalizuj", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7cd725830..4b0c0702b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -620,7 +620,9 @@ "copied": "Código copiado", "copy_failed": "Não foi possível copiar o código" }, - "mv_position": "{index} de {total}" + "mv_position": "{index} de {total}", + "plugin_panel_show": "Mostrar o painel do plugin", + "plugin_panel_hide": "Ocultar o painel do plugin" }, "email_composer": { "minimize": "Minimizar", diff --git a/locales/ro/common.json b/locales/ro/common.json index c71303c6b..9252a4761 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -620,7 +620,9 @@ "copied": "Cod copiat", "copy_failed": "Codul nu a putut fi copiat" }, - "mv_position": "{index} din {total}" + "mv_position": "{index} din {total}", + "plugin_panel_show": "Afișează panoul pluginului", + "plugin_panel_hide": "Ascunde panoul pluginului" }, "email_composer": { "minimize": "Minimizează", diff --git a/locales/ru/common.json b/locales/ru/common.json index 4596ec388..359f85ecb 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -620,7 +620,9 @@ "copied": "Код скопирован", "copy_failed": "Не удалось скопировать код" }, - "mv_position": "{index} из {total}" + "mv_position": "{index} из {total}", + "plugin_panel_show": "Показать панель плагина", + "plugin_panel_hide": "Скрыть панель плагина" }, "email_composer": { "minimize": "Свернуть", diff --git a/locales/sk/common.json b/locales/sk/common.json index 22f4c5530..e63d2a6e9 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -620,7 +620,9 @@ "copied": "Kód skopírovaný", "copy_failed": "Kód sa nepodarilo skopírovať" }, - "mv_position": "{index} z {total}" + "mv_position": "{index} z {total}", + "plugin_panel_show": "Zobraziť panel doplnku", + "plugin_panel_hide": "Skryť panel doplnku" }, "email_composer": { "minimize": "Minimalizovať", diff --git a/locales/tr/common.json b/locales/tr/common.json index acf237629..18127549b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -620,7 +620,9 @@ "copied": "Kod kopyalandı", "copy_failed": "Kod kopyalanamadı" }, - "mv_position": "{index} / {total}" + "mv_position": "{index} / {total}", + "plugin_panel_show": "Eklenti panelini göster", + "plugin_panel_hide": "Eklenti panelini gizle" }, "email_composer": { "minimize": "Simge durumuna küçült", diff --git a/locales/uk/common.json b/locales/uk/common.json index 8ce2a0b12..d28f9e356 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -620,7 +620,9 @@ "copied": "Код скопійовано", "copy_failed": "Не вдалося скопіювати код" }, - "mv_position": "{index} з {total}" + "mv_position": "{index} з {total}", + "plugin_panel_show": "Показати панель плагіна", + "plugin_panel_hide": "Сховати панель плагіна" }, "email_composer": { "minimize": "Згорнути", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1415806e0..787a71aba 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -620,7 +620,9 @@ "copied": "驗證碼已複製", "copy_failed": "無法複製驗證碼" }, - "mv_position": "第 {index} 封,共 {total} 封" + "mv_position": "第 {index} 封,共 {total} 封", + "plugin_panel_show": "顯示外掛程式面板", + "plugin_panel_hide": "隱藏外掛程式面板" }, "email_composer": { "minimize": "最小化", diff --git a/locales/zh/common.json b/locales/zh/common.json index 10342d03d..ec47531a7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -620,7 +620,9 @@ "copied": "验证码已复制", "copy_failed": "无法复制验证码" }, - "mv_position": "第 {index} 封,共 {total} 封" + "mv_position": "第 {index} 封,共 {total} 封", + "plugin_panel_show": "显示插件面板", + "plugin_panel_hide": "隐藏插件面板" }, "email_composer": { "minimize": "最小化", From ada81d5f8f1c635d50cf5d49774f5daa97c56656 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:01:13 +0200 Subject: [PATCH 027/147] fix: hide the Next.js dev badge inside plugin slots and warn that dev sandboxes are not isolated --- app/(sandbox)/layout.tsx | 5 +++++ lib/plugin-sandbox/host-bridge.ts | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 45dfe9386..09b74a652 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -19,6 +19,11 @@ export default function PluginSandboxLayout({ children }: { children: ReactNode return ( + {/* next dev puts its indicator into every document, so each plugin + slot showed its own badge. Plugins have no use for it. */} + {process.env.NODE_ENV === 'development' && ( + + )} {children} diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index ac2c9bcad..fd8aeb133 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -158,6 +158,7 @@ export class SandboxInstance { const sandboxFlags = this.privileged || process.env.NODE_ENV === 'development' ? 'allow-scripts allow-same-origin' : 'allow-scripts'; + if (!this.privileged && process.env.NODE_ENV === 'development') warnDevSandboxOnce(); this.iframe.setAttribute('sandbox', sandboxFlags); this.iframe.setAttribute('referrerpolicy', 'no-referrer'); this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`; @@ -388,6 +389,14 @@ export function withoutFunctions(value: unknown, depth = 0): unknown { return out; } +let devSandboxWarned = false; +function warnDevSandboxOnce(): void { + if (devSandboxWarned) return; + devSandboxWarned = true; + console.warn('[plugin-sandbox] next dev runs untrusted plugins same-origin (Next needs it for HMR). ' + + 'They can reach the whole app here; test isolation against a production build.'); +} + function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } From bf1d8fd2c36a8c7ae394bb01b5e4a00bc2a1735e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:02:12 +0200 Subject: [PATCH 028/147] fix: stop keeping unanswered plugin hook calls and old slot callbacks around --- .../plugin-host-bridge-origin.test.ts | 24 +++++++++++++ lib/plugin-sandbox/host-bridge.ts | 36 +++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index 5dd2d1598..462ff0b53 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -124,3 +124,27 @@ describe('shouldShow context', () => { expect(msg?.context).toEqual({ nested: { n: 1 } }); }); }); + +describe('host bookkeeping', () => { + it('fails a hook call whose arguments cannot be sent, without keeping it pending', async () => { + const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }); + instances.push(inst); + vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation(() => { throw new DOMException('no clone', 'DataCloneError'); }); + + await expect(inst.invokeHook('onEmailOpen', [() => {}])).rejects.toThrow(/no clone/); + expect((inst as unknown as { pendingHookInvokes: Map }).pendingHookInvokes.size).toBe(0); + }); + + it('drops callbacks of props that were replaced', () => { + const inst = createSlotInstance({ + plugin: plugin(), slot: 'email-banner', code: '', locale: 'en', tier: 'untrusted', extraProps: { a: () => 1 }, + hostContainer: document.body, onResize: () => {}, + }); + instances.push(inst); + const table = (inst as unknown as { callbackTable: Map }).callbackTable; + + for (let i = 0; i < 50; i++) inst.updateProps({ onAttach: () => i }); + + expect(table.size).toBe(1); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index fd8aeb133..475c9049c 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -122,6 +122,8 @@ export class SandboxInstance { private pendingShouldShow = new Map void>(); /** Host-side function references the sandbox can call back via 'callback-invoke'. */ private callbackTable = new Map unknown>(); + /** Ids in callbackTable that came from the current props. */ + private propCallbackIds = new Set(); constructor( private plugin: InstalledPlugin, @@ -137,7 +139,9 @@ export class SandboxInstance { // Slot iframes get `extraProps`; encode any function values now so the // structured-clone send doesn't drop them. if (initPayload.mode === 'slot') { + const before = new Set(this.callbackTable.keys()); initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record; + this.propCallbackIds = new Set([...this.callbackTable.keys()].filter((k) => !before.has(k))); } this.readyPromise = new Promise((res) => { this.resolveReady = res; }); @@ -315,9 +319,23 @@ export class SandboxInstance { if (this.destroyed) return Promise.reject(new Error('sandbox destroyed')); const id = uid(); const p = new Promise((resolve, reject) => { - this.pendingHookInvokes.set(id, { resolve, reject }); + // The bus gives up on a slow handler, but the entry used to stay here + // until the iframe answered, which a hung plugin never does. + const timer = setTimeout(() => { + if (this.pendingHookInvokes.delete(id)) reject(new Error(`hook ${hookName} got no answer`)); + }, HOOK_INVOKE_TTL_MS); + this.pendingHookInvokes.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v); }, + reject: (e) => { clearTimeout(timer); reject(e); }, + }); }); - this.send({ type: 'hook-invoke', id, hookName, args }); + try { + this.send({ type: 'hook-invoke', id, hookName, args }); + } catch (err) { + // Arguments that can't be cloned: fail this call, don't leak it. + this.pendingHookInvokes.get(id)?.reject(err as Error); + this.pendingHookInvokes.delete(id); + } return p; } @@ -354,10 +372,13 @@ export class SandboxInstance { updateProps(props: Record): void { if (this.destroyed) return; - // Stale references would leak if we kept growing the table without - // bound; for now we let it grow until destroy(). A future refinement - // could diff old vs new props and drop entries no longer referenced. - const encoded = encodeCallbacks(props, this.callbackTable) as Record; + // Callbacks from the props being replaced are dropped, so the table + // holds only what the slot can currently reach. + const fresh = new Map unknown>(); + const encoded = encodeCallbacks(props, fresh) as Record; + for (const id of this.propCallbackIds) this.callbackTable.delete(id); + this.propCallbackIds = new Set(fresh.keys()); + for (const [id, fn] of fresh) this.callbackTable.set(id, fn); this.send({ type: 'props-update', props: encoded }); } @@ -397,6 +418,9 @@ function warnDevSandboxOnce(): void { + 'They can reach the whole app here; test isolation against a production build.'); } +/** How long the host waits for a hook answer; longer than any bus budget. */ +const HOOK_INVOKE_TTL_MS = 11 * 60_000; + function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } From 8ef7ec022feef5ff32ae18d930947ad7e05c66c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:04:17 +0200 Subject: [PATCH 029/147] fix: deliver a plugin OAuth callback only to the plugin that started the flow --- .../plugin-oauth-callback-listener.test.tsx | 53 ++++++++++++++----- .../plugin-oauth-callback-listener.tsx | 9 +++- lib/plugin-hooks.ts | 12 +++++ lib/plugin-oauth.ts | 53 +++++++++++++++++++ lib/plugin-sandbox/host-api.ts | 13 ++++- lib/plugin-sandbox/runtime.tsx | 7 +++ 6 files changed, 133 insertions(+), 14 deletions(-) diff --git a/components/__tests__/plugin-oauth-callback-listener.test.tsx b/components/__tests__/plugin-oauth-callback-listener.test.tsx index 6b738810c..2c84da6d9 100644 --- a/components/__tests__/plugin-oauth-callback-listener.test.tsx +++ b/components/__tests__/plugin-oauth-callback-listener.test.tsx @@ -2,6 +2,7 @@ import { authHooks } from "@/lib/plugin-hooks"; import { PLUGIN_OAUTH_EVENT, PLUGIN_OAUTH_STORAGE_KEY, + createOAuthState, } from "@/lib/plugin-oauth"; import { act, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -25,6 +26,8 @@ function emitStorageEvent(newValue: string | null) { }); } +let STATE = ""; + describe("PluginOAuthCallbackListener", () => { let handler: (payload: unknown) => Promise; let disposable: { dispose: () => void }; @@ -35,6 +38,7 @@ describe("PluginOAuthCallbackListener", () => { mockUsePathname.mockReturnValue("/en/mail"); handler = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined); disposable = authHooks.onOAuthCallback.register("test-plugin", handler); + STATE = createOAuthState("test-plugin"); }); afterEach(() => { @@ -43,14 +47,14 @@ describe("PluginOAuthCallbackListener", () => { it("delivers a success payload via the storage event", () => { const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ code: "abc", state: "xyz" }), + expect.objectContaining({ code: "abc", state: STATE }), ); // Best-effort cleanup after delivery expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBeNull(); @@ -59,14 +63,14 @@ describe("PluginOAuthCallbackListener", () => { it("delivers an error payload (denial) with state", () => { const { unmount } = render(); - const payload = { state: "xyz", error: "access_denied", receivedAt: 1 }; + const payload = { state: STATE, error: "access_denied", receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ state: "xyz", error: "access_denied" }), + expect.objectContaining({ state: STATE, error: "access_denied" }), ); unmount(); }); @@ -74,7 +78,7 @@ describe("PluginOAuthCallbackListener", () => { it("does nothing on the callback route", () => { mockUsePathname.mockReturnValue("/en/plugins/oauth/callback"); const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); @@ -95,7 +99,7 @@ describe("PluginOAuthCallbackListener", () => { emitStorageEvent("not json"); emitStorageEvent(JSON.stringify({ foo: "bar" })); - emitStorageEvent(JSON.stringify({ code: 123, state: "xyz" })); + emitStorageEvent(JSON.stringify({ code: 123, state: STATE })); emitStorageEvent(null); expect(handler).not.toHaveBeenCalled(); @@ -119,7 +123,7 @@ describe("PluginOAuthCallbackListener", () => { try { vi.setSystemTime(0); const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); @@ -127,10 +131,10 @@ describe("PluginOAuthCallbackListener", () => { expect(handler).toHaveBeenCalledOnce(); - // Outside the dedup window the same payload is delivered again + // A state is used once: replaying it later delivers nothing either. vi.setSystemTime(61_000); emitStorageEvent(JSON.stringify(payload)); - expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledOnce(); unmount(); } finally { @@ -140,7 +144,7 @@ describe("PluginOAuthCallbackListener", () => { it("delivers same-tab custom events", () => { const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; act(() => { window.dispatchEvent( @@ -150,7 +154,7 @@ describe("PluginOAuthCallbackListener", () => { expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ code: "abc", state: "xyz" }), + expect.objectContaining({ code: "abc", state: STATE }), ); unmount(); }); @@ -159,7 +163,7 @@ describe("PluginOAuthCallbackListener", () => { const { unmount } = render(); unmount(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); act(() => { @@ -170,4 +174,29 @@ describe("PluginOAuthCallbackListener", () => { expect(handler).not.toHaveBeenCalled(); }); + + it("delivers the code only to the plugin that started the flow", () => { + // Every plugin with http:fetch used to receive every code. + const other = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined); + const otherDisposable = authHooks.onOAuthCallback.register("other-plugin", other); + const { unmount } = render(); + + emitStorageEvent(JSON.stringify({ code: "abc", state: STATE, receivedAt: 1 })); + + expect(handler).toHaveBeenCalledOnce(); + expect(other).not.toHaveBeenCalled(); + otherDisposable.dispose(); + unmount(); + }); + + it("drops a callback whose state no plugin created", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { unmount } = render(); + + emitStorageEvent(JSON.stringify({ code: "abc", state: "made-up", receivedAt: 1 })); + + expect(handler).not.toHaveBeenCalled(); + warn.mockRestore(); + unmount(); + }); }); diff --git a/components/providers/plugin-oauth-callback-listener.tsx b/components/providers/plugin-oauth-callback-listener.tsx index 3b4299257..e610c9665 100644 --- a/components/providers/plugin-oauth-callback-listener.tsx +++ b/components/providers/plugin-oauth-callback-listener.tsx @@ -20,6 +20,7 @@ import { authHooks } from '@/lib/plugin-hooks'; import { PLUGIN_OAUTH_EVENT as EVENT_NAME, PLUGIN_OAUTH_STORAGE_KEY as STORAGE_KEY, + takeOAuthStateOwner, type OAuthCallbackPayload, } from '@/lib/plugin-oauth'; import { usePathname } from 'next/navigation'; @@ -83,7 +84,13 @@ export function PluginOAuthCallbackListener() { lastCode = code; lastState = payload.state ?? null; lastAt = now; - void authHooks.onOAuthCallback.emit(payload); + // Only the plugin that created this state gets the code. + const pluginId = takeOAuthStateOwner(payload.state); + if (pluginId) { + void authHooks.onOAuthCallback.emitTo(pluginId, payload); + } else { + console.warn('[plugin-oauth] callback with a state no plugin asked for; ignored'); + } // Best-effort cleanup so a reload doesn't replay an old payload. try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ } }; diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 58b2ffd38..3c6c48dcf 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -193,6 +193,18 @@ export class HookBus any> { } } + /** Observer emit to one plugin's handlers only. */ + async emitTo(pluginId: string, ...args: Parameters): Promise { + for (const { pluginId: owner, handler } of this.handlers) { + if (owner !== pluginId || pluginErrorTracker.isDisabled(owner)) continue; + try { + await withTimeout(handler(...args), this.timeoutMs); + } catch (err) { + this.recordFailure(owner, err); + } + } + } + /** Fire handlers as interceptors - any returning false cancels the operation */ async intercept(...args: Parameters): Promise { return (await this.interceptOutcome(...args)) === 'allowed'; diff --git a/lib/plugin-oauth.ts b/lib/plugin-oauth.ts index 16149e104..d5fb09fe1 100644 --- a/lib/plugin-oauth.ts +++ b/lib/plugin-oauth.ts @@ -30,3 +30,56 @@ export type OAuthCallbackPayload = error_description?: string; receivedAt?: number; }; + +// ─── State values bound to the plugin that started the flow ─── + +const STATES_KEY = "plugin-oauth-states"; +/** A flow that takes longer than this is abandoned. */ +const STATE_TTL_MS = 15 * 60_000; + +type StateTable = Record; + +function readStates(): StateTable { + try { + const raw = localStorage.getItem(STATES_KEY); + const parsed = raw ? JSON.parse(raw) as StateTable : {}; + const now = Date.now(); + for (const [state, entry] of Object.entries(parsed)) { + if (!entry || typeof entry.pluginId !== "string" || now - entry.created > STATE_TTL_MS) delete parsed[state]; + } + return parsed; + } catch { + return {}; + } +} + +function writeStates(table: StateTable): void { + try { localStorage.setItem(STATES_KEY, JSON.stringify(table)); } catch { /* storage blocked */ } +} + +/** + * A fresh `state` for a plugin's OAuth redirect. The callback is delivered + * only to the plugin that asked for this value; before, every plugin with + * http:fetch received every code. Kept in localStorage so the tab that gets + * the callback event finds it even if another tab started the flow. + */ +export function createOAuthState(pluginId: string): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + const table = readStates(); + table[state] = { pluginId, created: Date.now() }; + writeStates(table); + return state; +} + +/** The plugin a callback belongs to, consumed so it can't be delivered twice. */ +export function takeOAuthStateOwner(state: string | undefined): string | null { + if (!state) return null; + const table = readStates(); + const entry = table[state]; + if (!entry) return null; + delete table[state]; + writeStates(table); + return entry.pluginId; +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index b421f94f6..bd0db432d 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -19,7 +19,8 @@ import { type KeywordVisibility, } from '@/stores/settings-store'; import type { MessageListTabsConfig } from '../plugin-types'; -import { apiFetch, getPathPrefix } from '../browser-navigation'; +import { apiFetch, getPathPrefix, withBasePath } from '../browser-navigation'; +import { createOAuthState } from '../plugin-oauth'; import { reportUploadProgress } from '../upload-progress'; import { DEFAULT_KEYWORD_SCAN_LIMIT } from '../jmap/client'; import { suggestKeywordColor } from '../keyword-discovery'; @@ -164,6 +165,10 @@ const PERM_PER_METHOD: Record = { // message-list search: the plugin never sees the results, but it decides // which messages the list shows. 'search.run': 'email:read', + // OAuth with a third party: a state value bound to this plugin, and the + // redirect URI the provider sends the user back to. + 'oauth.createState': 'http:fetch', + 'oauth.getRedirectUri': 'http:fetch', // sieve (delivery-time classification) 'sieve.isSupported': 'filters:read', 'sieve.getActiveScript': 'filters:read', @@ -1810,6 +1815,12 @@ export async function dispatchApiCall( case 'search.run': return doSearchRun(plugin, args[0], args[1]); + case 'oauth.createState': return createOAuthState(plugin.id); + case 'oauth.getRedirectUri': { + const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ || 'en'; + return `${window.location.origin}${withBasePath(`/${encodeURIComponent(locale)}/plugins/oauth/callback`)}`; + } + case 'sieve.isSupported': { const { client } = useAuthStore.getState(); return !!client?.supportsSieve(); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 21607f4c1..557c896fa 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -488,6 +488,13 @@ function buildPluginApi(manifest: PluginManifest) { run: (query: unknown, options?: { label?: string }) => callApi('search.run', [query, options]) as Promise, }, + // OAuth with a third party (permission: http:fetch). Put createState()'s + // value in the authorize URL; the code comes back through the + // onOAuthCallback hook, to this plugin only. + oauth: { + createState: () => callApi('oauth.createState', []) as Promise, + getRedirectUri: () => callApi('oauth.getRedirectUri', []) as Promise, + }, // Sieve integration for delivery-time classification (permissions: // filters:read / filters:write). Plugins never write scripts directly - // they register an onSieveScriptGenerate transform hook and call From 480a1bb226bdd5910818578e018d710f0450eb43 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:05:05 +0200 Subject: [PATCH 030/147] fix: clear a plugin's stored data when the admin removes it, not only on uninstall --- lib/__tests__/plugin-store.test.ts | 17 +++++++++++ stores/plugin-store.ts | 49 +++++++++++++++--------------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 4aac4b9da..f5813179a 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -355,3 +355,20 @@ describe('updatePluginSettings', () => { expect(bridge.setSettings).toHaveBeenCalledWith({ size: 512 }); }); }); + +describe('plugins the admin removed', () => { + it('lose what they stored, like an uninstalled plugin', async () => { + // Their data used to survive and come back when the admin re-added them. + resetStore(); + localStorage.setItem('server-managed-plugin-ids', JSON.stringify(['gone'])); + localStorage.setItem('plugin:gone:@acct:notes', '"secret"'); + usePluginStore.setState({ plugins: [mockPlugin({ id: 'gone', managed: true })] }); + mocks.apiFetch.mockImplementation(async () => new Response(JSON.stringify({ plugins: [], themes: [] }))); + + await usePluginStore.getState().initializePlugins(); + + expect(usePluginStore.getState().plugins.some((p) => p.id === 'gone')).toBe(false); + expect(localStorage.getItem('plugin:gone:@acct:notes')).toBeNull(); + expect(pluginStorage.deletePreview).toHaveBeenCalledWith('gone'); + }); +}); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index fede3c8ee..e313e1569 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -150,25 +150,7 @@ export const usePluginStore = create()( const forceEnabledByPolicy = usePolicyStore.getState().isPluginForceEnabled(id); if (plugin.forceEnabled || forceEnabledByPolicy) return; - // Deactivate if running - deactivatePlugin(id); - removeAllPluginHooks(id); - - // Clean up storage - pluginStorage.deleteCode(id); - pluginStorage.deletePreview(id); - - // Remove plugin-scoped localStorage entries - if (typeof window !== 'undefined') { - const prefix = `plugin:${id}:`; - const keysToRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith(prefix)) keysToRemove.push(key); - } - keysToRemove.forEach(k => localStorage.removeItem(k)); - } - + removePluginData(id); set({ plugins: plugins.filter(p => p.id !== id) }); }, @@ -353,6 +335,27 @@ export const usePluginStore = create()( ) ); +/** + * Everything a plugin left in this browser: its running iframes and hooks, + * its code and preview, and what it stored for any account. Used when the + * user removes a plugin and when the admin does. + */ +function removePluginData(id: string): void { + deactivatePlugin(id); + removeAllPluginHooks(id); + void pluginStorage.deleteCode(id).catch(() => {}); + void pluginStorage.deletePreview(id).catch(() => {}); + if (typeof window !== 'undefined') { + const prefix = `plugin:${id}:`; + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(prefix)) keysToRemove.push(key); + } + keysToRemove.forEach(k => localStorage.removeItem(k)); + } +} + /** Give the loader its way back into the store. */ function wireLoader( get: () => PluginStoreState, @@ -573,11 +576,9 @@ async function syncServerPlugins( // Remove plugins that were previously server-managed but no longer on the server const staleIds = [...prevServerIds].filter(id => !serverPluginIds.has(id)); if (staleIds.length > 0) { - for (const id of staleIds) { - deactivatePlugin(id); - removeAllPluginHooks(id); - pluginStorage.deleteCode(id); - } + // The admin removed them: same cleanup as an uninstall, so their data + // doesn't come back if the admin installs the plugin again. + for (const id of staleIds) removePluginData(id); const staleSet = new Set(staleIds); set(state => ({ plugins: state.plugins.filter(p => !staleSet.has(p.id)), From a3cb9e630ed49f1e7d5e12c80db1a1b3d0c97635 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:14:29 +0200 Subject: [PATCH 031/147] refactor: describe every plugin hook in one catalogue The hook buses, the permission each hook needs and the docs were three separate lists that had drifted: 145 of 198 hooks were never fired and nothing noticed. `lib/plugin-hook-catalog.ts` now names each hook with its kind, permission, payload and when it fires; the permission check reads it, and a test fails for any catalogued hook without a call site. Groups still being wired in this branch are listed in that test. Also: - new before-hooks for archive, spam, read state, star, keywords, folder changes, RSVP, event import, tasks, file moves, settings, identities, filters and out-of-office, plus onComposerClose and onSyncStateChange - removed hooks that can never fire: the S/MIME group (S/MIME is a plugin now), onMobileViewSwitch, onSidebarAppChange and the registerShortcut pseudo-hook - smime:read no longer grants anything; manifests that name it still install, with a warning --- lib/__tests__/extension-package.test.ts | 9 + lib/__tests__/plugin-hook-catalog.test.ts | 79 ++++++ lib/extension-package.ts | 6 + lib/plugin-hook-catalog.ts | 316 ++++++++++++++++++++++ lib/plugin-hooks.ts | 34 ++- lib/plugin-sandbox/loader.ts | 4 +- lib/plugin-sandbox/permissions.ts | 238 +--------------- lib/plugin-types.ts | 9 +- 8 files changed, 449 insertions(+), 246 deletions(-) create mode 100644 lib/__tests__/plugin-hook-catalog.test.ts create mode 100644 lib/plugin-hook-catalog.ts diff --git a/lib/__tests__/extension-package.test.ts b/lib/__tests__/extension-package.test.ts index ce0945a0d..bf1f0f81c 100644 --- a/lib/__tests__/extension-package.test.ts +++ b/lib/__tests__/extension-package.test.ts @@ -133,3 +133,12 @@ describe('compareVersions', () => { expect(compareVersions('0.9.0', '1.0.0')).toBe(-1); }); }); + +describe('deprecated permissions', () => { + it('drops smime:read with a warning instead of refusing the plugin', () => { + const { manifest, warnings } = checkPluginManifest({ ...BASE, permissions: ['email:read', 'smime:read'] }); + + expect(manifest?.permissions).toEqual(['email:read']); + expect(warnings.join()).toMatch(/smime:read/); + }); +}); diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts new file mode 100644 index 000000000..a1e1a928a --- /dev/null +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { HOOK_CATALOG, type HookGroup } from '../plugin-hook-catalog'; +import * as hooks from '../plugin-hooks'; +import { ALL_PERMISSIONS } from '../plugin-types'; + +const root = path.resolve(__dirname, '../..'); + +/** Every HookBus by name, from all the exported hook groups. */ +function allBuses(): Record { + const out: Record = {}; + for (const [groupName, group] of Object.entries(hooks)) { + if (!groupName.endsWith('Hooks') || typeof group !== 'object' || group === null) continue; + for (const [hookName, bus] of Object.entries(group)) { + if (bus instanceof hooks.HookBus) out[hookName] = groupName; + } + } + return out; +} + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (['node_modules', '.next', 'repos', '__tests__', '.git', 'local-data', 'integration', 'scripts'].includes(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...sourceFiles(full)); + else if (/\.(tsx?)$/.test(entry.name) && !/\.test\./.test(entry.name)) out.push(full); + } + return out; +} + +const sources = ['app', 'components', 'hooks', 'lib', 'stores'].flatMap((d) => sourceFiles(path.join(root, d))) + .map((f) => fs.readFileSync(f, 'utf8')) + .join('\n'); + +/** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */ +function isFired(hookName: string, groupName: string): boolean { + const re = new RegExp(`\\b${groupName}\\.${hookName}\\.(emit|emitSync|emitTo|intercept|interceptOutcome|transform|takeOver)\\b`); + return re.test(sources); +} + +// Groups whose call sites are still being wired in this branch. Each +// commit that wires a group removes it here, and the test then insists every +// hook in it has a call site. +const NOT_YET_WIRED = new Set([ + 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', + 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', + 'filters', 'vacation', 'templates', 'auth', 'security', 'theme', 'notifications', 'app', 'ui', 'keyboard', +]); + +describe('hook catalogue', () => { + const buses = allBuses(); + + it('has a bus for every catalogued hook and no bus without an entry', () => { + expect(Object.keys(buses).sort()).toEqual(Object.keys(HOOK_CATALOG).sort()); + }); + + it('gates every hook behind a permission the host knows', () => { + const known = new Set(ALL_PERMISSIONS); + const unknown = Object.entries(HOOK_CATALOG).filter(([, spec]) => !known.has(spec.permission)).map(([n]) => n); + expect(unknown).toEqual([]); + }); + + const groups = [...new Set(Object.values(HOOK_CATALOG).map((s) => s.group))]; + it.each(groups)('fires every %s hook somewhere in the app', (group) => { + const missing = Object.entries(HOOK_CATALOG) + .filter(([name, spec]) => spec.group === group && !isFired(name, buses[name])) + .map(([name]) => name); + if (NOT_YET_WIRED.has(group)) { + // Still being wired: the list must not claim a group is unwired once + // it is fully wired, or the check would never switch on. + expect(missing.length, `${group} is fully wired; remove it from NOT_YET_WIRED`).toBeGreaterThan(0); + } else { + expect(missing).toEqual([]); + } + }); +}); diff --git a/lib/extension-package.ts b/lib/extension-package.ts index 548cf2124..9b7f04c1c 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -10,6 +10,7 @@ import JSZip from 'jszip'; import { ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES, + DEPRECATED_PERMISSIONS, MAX_PLUGIN_SIZE, MAX_THEME_SIZE, type PluginManifest, @@ -427,6 +428,11 @@ export function checkPluginManifest(m: Record, opts: { appVersi errors.push('"permissions" must be a list of strings'); } else { permissions = [...new Set(m.permissions as string[])]; + const deprecated = permissions.filter((p) => DEPRECATED_PERMISSIONS.includes(p)); + if (deprecated.length > 0) { + warnings.push(`Ignored permissions that no longer grant anything: ${deprecated.join(', ')}`); + permissions = permissions.filter((p) => !DEPRECATED_PERMISSIONS.includes(p)); + } const unknown = permissions.filter((p) => !KNOWN_PERMISSIONS.has(p)); if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')} (this Bulwark version does not offer them)`); } diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts new file mode 100644 index 000000000..689d15778 --- /dev/null +++ b/lib/plugin-hook-catalog.ts @@ -0,0 +1,316 @@ +// Every plugin hook the host offers: what kind it is, which permission a +// plugin needs for it, what it receives, and where the host fires it. +// +// This is the single source of truth. The permission table in +// plugin-sandbox/permissions.ts, the hook reference in the plugin docs and +// the "every hook is fired" test (lib/__tests__/plugin-hook-catalog.test.ts) +// all read it, so a hook can't be declared without a call site, a permission +// and a description. +// +// Kinds: +// - observer: told after something happened; the return value is ignored. +// - intercept: asked before something happens; return false to stop it. +// - transform: handed a value and returns a changed one (or undefined to +// leave it alone); handlers run in order, each sees the previous result. +// - takeover: may do the action itself instead of the host (onComposeSend). +// +// Payloads cross into a sandboxed iframe, so they are plain data. Changing a +// payload object in place has no effect on the host; use the return value. + +import type { Permission } from './plugin-types'; + +export type HookKind = 'observer' | 'intercept' | 'transform' | 'takeover'; + +export type HookGroup = + | 'mail-list' | 'mail-read' | 'mail-compose' | 'mail-send' | 'mail-organize' | 'mail-mailboxes' + | 'mail-search' | 'mail-attachments' | 'mail-sync' + | 'calendar' | 'contacts' | 'tasks' | 'files' + | 'settings' | 'identities' | 'filters' | 'vacation' | 'templates' + | 'auth' | 'security' | 'navigation' | 'theme' | 'notifications' | 'app' | 'ui' | 'keyboard' | 'tabs'; + +export interface HookSpec { + group: HookGroup; + kind: HookKind; + permission: Permission; + /** What the handler receives (and, for transforms, returns). */ + payload: string; + /** When the host fires it. */ + when: string; +} + +const H = (group: HookGroup, kind: HookKind, permission: Permission, payload: string, when: string): HookSpec => + ({ group, kind, permission, payload, when }); + +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'), + onEmailListItemRender: H('mail-list', 'transform', 'email:render-takeover', 'EmailListBadge[], { email: EmailSummary }', 'for each visible row; returned badges are shown next to the subject'), + onEmailSelectionChange: H('mail-list', 'observer', 'email:read', '{ emailIds: string[] }', 'when the set of selected messages changes'), + onMailboxChange: H('mail-list', 'observer', 'email:read', '{ mailboxId, role, previousMailboxId }', 'when the user opens another folder'), + + // ─── Mail: reading ───────────────────────────────────────── + onEmailOpen: H('mail-read', 'observer', 'email:read', 'EmailReadView', 'when a message is opened'), + onEmailClose: H('mail-read', 'observer', 'email:read', '{ emailId }', 'when the open message is closed or replaced'), + onRenderEmailBody: H('mail-read', 'transform', 'email:render-takeover', 'RenderableBody, MessageContext', 'before the body of an opened message is rendered'), + onEmailContentRender: H('mail-read', 'observer', 'email:read', '{ emailId, hasHtml, hasExternalContent }', 'after the body of an opened message is on screen'), + onThreadExpand: H('mail-read', 'observer', 'email:read', '{ threadId, emailIds, expanded }', 'when a conversation is expanded or collapsed'), + onBeforeReply: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Reply is chosen, before the composer opens'), + onBeforeReplyAll: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Reply all is chosen, before the composer opens'), + onBeforeForward: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Forward is chosen, before the composer opens'), + onBeforeExternalLink: H('mail-read', 'intercept', 'ui:observe', 'ExternalLinkContext', 'when a link in a message is clicked; return false to stop it or { href } to change it'), + onAvatarResolve: H('mail-read', 'transform', 'email:read', 'string | null (avatar URL), { email, name }', 'when a sender avatar is needed'), + + // ─── Mail: composing ─────────────────────────────────────── + onBeforeCompose: H('mail-compose', 'intercept', 'email:send', 'ComposeOptions', 'before the composer opens; return false to stop it or a ComposeOptions to prefill it'), + onComposerOpen: H('mail-compose', 'observer', 'email:read', 'ComposerContext', 'after the composer opened'), + onComposerClose: H('mail-compose', 'observer', 'email:read', '{ draftId, sent: boolean }', 'after the composer closed'), + onDraftChange: H('mail-compose', 'observer', 'email:read', 'DraftView', 'while the user writes (debounced)'), + onBeforeDraftAutoSave: H('mail-compose', 'transform', 'email:send', 'AlmostSavedDraft', 'before a draft is saved; return false to skip this save'), + onDraftAutoSave: H('mail-compose', 'observer', 'email:read', '{ draftId, subject }', 'after a draft was saved'), + onBeforeEditDraft: H('mail-compose', 'transform', 'email:send', 'Email', 'before a saved draft is opened in the composer'), + onBuildQuoteHeader: H('mail-compose', 'transform', 'email:send', 'QuoteHeader, QuoteHeaderContext', 'when a reply or forward is set up'), + onRecipientChipsChange: H('mail-compose', 'transform', 'email:send', 'Recipient[]', 'when the recipient fields change'), + onBeforeComposeOpenToReply: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a reply is filled in'), + onBeforeComposeOpenToReplyAll: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a reply-all is filled in'), + onBeforeComposeOpenToForward: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a forward is filled in'), + onBeforeComposeOpenToForwardAsAttachment: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a forward-as-attachment is filled in'), + onMailtoIntercept: H('mail-compose', 'intercept', 'email:read', 'MailtoContext', 'when a mailto: link is opened; return false to stop the composer'), + + // ─── Mail: sending ───────────────────────────────────────── + onBeforeEmailSend: H('mail-send', 'intercept', 'email:send', 'OutgoingEmail', 'when Send is pressed; a handler that fails or times out stops the send'), + onTransformOutgoingEmail: H('mail-send', 'transform', 'email:send', 'OutgoingEmail', 'right before the message is submitted'), + onComposeSend: H('mail-send', 'takeover', 'email:send', 'ComposeSendRequest', 'at the start of sending; may send the message itself'), + onAfterEmailSend: H('mail-send', 'observer', 'email:read', '{ emailId, submissionId, to, cc, bcc, subject, scheduledFor }', 'after the server accepted the message'), + + // ─── Mail: organizing ────────────────────────────────────── + onBeforeEmailDelete: H('mail-organize', 'intercept', 'email:write', '{ emailIds, permanent }', 'before messages are deleted or moved to Trash'), + onAfterEmailDelete: H('mail-organize', 'observer', 'email:read', '{ emailIds, permanent }', 'after messages were deleted'), + onBeforeEmailMove: H('mail-organize', 'intercept', 'email:write', '{ emailIds, fromMailboxId, toMailboxId }', 'before messages are moved'), + onAfterEmailMove: H('mail-organize', 'observer', 'email:read', '{ emailIds, fromMailboxId, toMailboxId }', 'after messages were moved'), + onBeforeEmailArchive: H('mail-organize', 'intercept', 'email:write', '{ emailIds }', 'before messages are archived'), + onEmailArchive: H('mail-organize', 'observer', 'email:read', '{ emailIds }', 'after messages were archived'), + onEmailUnarchive: H('mail-organize', 'observer', 'email:read', '{ emailIds, toMailboxId }', 'after messages were moved out of the archive'), + onBeforeEmailSpamToggle: H('mail-organize', 'intercept', 'email:write', '{ emailIds, spam }', 'before messages are marked as spam or not spam'), + onEmailSpamToggle: H('mail-organize', 'observer', 'email:read', '{ emailIds, spam }', 'after messages were marked as spam or not spam'), + onBeforeEmailReadStateChange: H('mail-organize', 'intercept', 'email:write', '{ emailIds, read }', 'before messages are marked read or unread'), + onEmailReadStateChange: H('mail-organize', 'observer', 'email:read', '{ emailIds, read }', 'after messages were marked read or unread'), + onBeforeEmailStarToggle: H('mail-organize', 'intercept', 'email:write', '{ emailIds, starred }', 'before messages are starred or unstarred'), + onEmailStarToggle: H('mail-organize', 'observer', 'email:read', '{ emailIds, starred }', 'after messages were starred or unstarred'), + onBeforeEmailKeywordChange: H('mail-organize', 'intercept', 'email:write', '{ emailIds, added: string[], removed: string[] }', 'before tags or other keywords are changed'), + onEmailKeywordChange: H('mail-organize', 'observer', 'email:read', '{ emailIds, added: string[], removed: string[] }', 'after tags or other keywords changed'), + onBeforeEmailCategorize: H('mail-organize', 'intercept', 'email:read', 'EmailCategorizeContext', 'before messages are moved to another inbox tab'), + onEmailCategorize: H('mail-organize', 'observer', 'email:read', 'EmailCategorizeContext', 'after messages were moved to another inbox tab'), + + // ─── Mail: folders ───────────────────────────────────────── + onMailboxesRefresh: H('mail-mailboxes', 'observer', 'email:read', 'MailboxView[]', 'after the folder list was loaded or refreshed'), + onBeforeMailboxCreate: H('mail-mailboxes', 'intercept', 'email:write', '{ name, parentId }', 'before a folder is created'), + onMailboxCreate: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, name, parentId }', 'after a folder was created'), + onBeforeMailboxRename: H('mail-mailboxes', 'intercept', 'email:write', '{ mailboxId, name }', 'before a folder is renamed'), + onMailboxRename: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, name, previousName }', 'after a folder was renamed'), + onBeforeMailboxDelete: H('mail-mailboxes', 'intercept', 'email:write', '{ mailboxId, name }', 'before a folder is deleted'), + onMailboxDelete: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, name }', 'after a folder was deleted'), + onBeforeMailboxEmpty: H('mail-mailboxes', 'intercept', 'email:write', '{ mailboxId, role }', 'before a folder (usually Trash or Spam) is emptied'), + onMailboxEmpty: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, role, count }', 'after a folder was emptied'), + onQuotaChange: H('mail-mailboxes', 'observer', 'email:read', 'QuotaView', 'when the storage quota was read and changed'), + + // ─── Mail: search ────────────────────────────────────────── + onSearch: H('mail-search', 'transform', 'email:read', '{ query, filters: SearchFilters }', 'before a search runs; may change the query and filters'), + onSearchResults: H('mail-search', 'observer', 'email:read', '{ query, count }', 'after search results arrived'), + onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search'), + + // ─── Mail: attachments ───────────────────────────────────── + onBeforeAttachmentUpload: H('mail-attachments', 'intercept', 'email:read', 'AttachmentInfo', 'before a file is attached in the composer'), + onBeforeBlobUpload: H('mail-attachments', 'transform', 'email:blob-write', '{ file, blobId } | ExternalAttachmentResult', 'before an attachment is uploaded; may offload it'), + onAfterAttachmentUpload: H('mail-attachments', 'observer', 'email:read', 'AttachmentInfo', 'after an attachment was uploaded'), + onAttachmentDownload: H('mail-attachments', 'observer', 'email:read', 'AttachmentInfo', 'when an attachment of a message is downloaded'), + onAttachmentPreview: H('mail-attachments', 'transform', 'email:render-takeover', 'AttachmentPreview, AttachmentInfo', 'when an attachment preview opens'), + + // ─── Mail: new mail and sync ─────────────────────────────── + onNewEmailReceived: H('mail-sync', 'observer', 'email:read', 'EmailSummary', 'for each new message in the inbox (push or poll)'), + onPushConnectionChange: H('mail-sync', 'observer', 'email:read', '{ connected: boolean }', 'when live updates connect or drop'), + onSyncStateChange: H('mail-sync', 'observer', 'email:read', '{ state: "syncing" | "idle" | "error", error? }', 'when the app starts or finishes fetching changes from the server'), + + // ─── Calendar ────────────────────────────────────────────── + onCalendarEventOpen: H('calendar', 'observer', 'calendar:read', 'CalendarEventView', 'when an event is opened'), + onCalendarEventFormOpen: H('calendar', 'observer', 'calendar:read', 'CalendarEventFormView', 'when the event form opens'), + onCalendarEventFormSave: H('calendar', 'transform', 'calendar:write', 'CalendarEventFormSavePatch, CalendarEventFormView', 'while the event form is saved'), + onCheckEventConflicts: H('calendar', 'transform', 'calendar:read', 'ConflictWarning[], { event }', 'while start or end change in the event form'), + onBeforeEventCreate: H('calendar', 'intercept', 'calendar:write', 'CalendarEventView', 'before an event is created'), + onAfterEventCreate: H('calendar', 'observer', 'calendar:read', 'CalendarEventView', 'after an event was created'), + onBeforeEventUpdate: H('calendar', 'intercept', 'calendar:write', '{ eventId, patch }', 'before an event is changed'), + onAfterEventUpdate: H('calendar', 'observer', 'calendar:read', 'CalendarEventView', 'after an event was changed'), + onBeforeEventDelete: H('calendar', 'intercept', 'calendar:write', '{ eventId, title }', 'before an event is deleted'), + onAfterEventDelete: H('calendar', 'observer', 'calendar:read', '{ eventId, title }', 'after an event was deleted'), + onBeforeEventRsvp: H('calendar', 'intercept', 'calendar:write', '{ eventId, status }', 'before an invitation is answered'), + onEventRsvp: H('calendar', 'observer', 'calendar:read', '{ eventId, status }', 'after an invitation was answered'), + onBeforeEventsImport: H('calendar', 'intercept', 'calendar:write', '{ calendarId, count }', 'before events from a file are imported'), + onEventsImport: H('calendar', 'observer', 'calendar:read', '{ calendarId, imported, skipped }', 'after events were imported'), + onCalendarDateChange: H('calendar', 'observer', 'calendar:read', '{ date }', 'when the shown date changes'), + onCalendarViewChange: H('calendar', 'observer', 'calendar:read', '{ view }', 'when day, week, month or agenda view is chosen'), + 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'), + + // ─── Contacts ────────────────────────────────────────────── + onContactOpen: H('contacts', 'observer', 'contacts:read', 'ContactView', 'when a contact is opened'), + onBeforeContactCreate: H('contacts', 'intercept', 'contacts:write', 'ContactView', 'before a contact is created'), + onAfterContactCreate: H('contacts', 'observer', 'contacts:read', 'ContactView', 'after a contact was created'), + onBeforeContactUpdate: H('contacts', 'intercept', 'contacts:write', '{ contactId, patch }', 'before a contact is changed'), + onAfterContactUpdate: H('contacts', 'observer', 'contacts:read', 'ContactView', 'after a contact was changed'), + onBeforeContactDelete: H('contacts', 'intercept', 'contacts:write', '{ contactIds }', 'before contacts are deleted'), + onAfterContactDelete: H('contacts', 'observer', 'contacts:read', '{ contactIds }', 'after contacts were deleted'), + onContactsImport: H('contacts', 'observer', 'contacts:read', '{ addressBookId, imported }', 'after contacts were imported'), + onContactSelectionChange: H('contacts', 'observer', 'contacts:read', '{ contactIds }', 'when the selected contacts change'), + onContactGroupChange: H('contacts', 'observer', 'contacts:read', '{ groupId, change: "created" | "updated" | "deleted" }', 'after a contact group changed'), + onContactGroupMemberChange: H('contacts', 'observer', 'contacts:read', '{ groupId, added, removed }', 'after members were added to or removed from a group'), + onContactMove: H('contacts', 'observer', 'contacts:read', '{ contactId, toAddressBookId }', 'after a contact moved to another address book'), + onProvideRecipientSuggestions: H('contacts', 'transform', 'contacts:read', 'RecipientSuggestion[], { query }', 'to add suggestions to recipient autocomplete'), + + // ─── Tasks ───────────────────────────────────────────────── + onTasksLoaded: H('tasks', 'observer', 'tasks:read', 'TaskView[]', 'after the task list was loaded'), + onBeforeTaskCreate: H('tasks', 'intercept', 'tasks:write', 'TaskView', 'before a task is created'), + onTaskCreate: H('tasks', 'observer', 'tasks:read', 'TaskView', 'after a task was created'), + onBeforeTaskUpdate: H('tasks', 'intercept', 'tasks:write', '{ taskId, patch }', 'before a task is changed'), + onTaskUpdate: H('tasks', 'observer', 'tasks:read', 'TaskView', 'after a task was changed'), + onBeforeTaskDelete: H('tasks', 'intercept', 'tasks:write', '{ taskId, title }', 'before a task is deleted'), + onTaskDelete: H('tasks', 'observer', 'tasks:read', '{ taskId, title }', 'after a task was deleted'), + onTaskToggleComplete: H('tasks', 'observer', 'tasks:read', '{ taskId, complete }', 'after a task was checked off or reopened'), + onTaskFilterChange: H('tasks', 'observer', 'tasks:read', '{ filter, showCompleted }', 'when the task filter changes'), + + // ─── Files ───────────────────────────────────────────────── + onFileNavigate: H('files', 'observer', 'files:read', '{ path }', 'when another folder is opened'), + onBeforeFileUpload: H('files', 'intercept', 'files:write', '{ name, size, type, path }', 'before a file is uploaded'), + onAfterFileUpload: H('files', 'observer', 'files:read', 'FileResourceView', 'after a file was uploaded'), + onFileUploadCancel: H('files', 'observer', 'files:read', '{ name, path }', 'when an upload is cancelled'), + onFileDownload: H('files', 'observer', 'files:read', 'FileResourceView', 'when a file is downloaded'), + onDirectoryCreate: H('files', 'observer', 'files:read', 'FileResourceView', 'after a folder was created'), + onBeforeFileDelete: H('files', 'intercept', 'files:write', '{ files: FileResourceView[] }', 'before files are deleted'), + onAfterFileDelete: H('files', 'observer', 'files:read', '{ files: FileResourceView[] }', 'after files were deleted'), + onBeforeFileRename: H('files', 'intercept', 'files:write', '{ file, newName }', 'before a file is renamed'), + onFileRename: H('files', 'observer', 'files:read', '{ file, previousName }', 'after a file was renamed'), + onBeforeFileMove: H('files', 'intercept', 'files:write', '{ files, toPath }', 'before files are moved'), + onFileMove: H('files', 'observer', 'files:read', '{ files, toPath }', 'after files were moved'), + onFileCopy: H('files', 'observer', 'files:read', '{ files, toPath }', 'after files were copied'), + onFileDuplicate: H('files', 'observer', 'files:read', 'FileResourceView', 'after a file was duplicated'), + onFileFavoriteToggle: H('files', 'observer', 'files:read', '{ file, favorite }', 'when a file is starred or unstarred'), + onFileSelectionChange: H('files', 'observer', 'files:read', '{ files: FileResourceView[] }', 'when the selected files change'), + onFileUndo: H('files', 'observer', 'files:read', '{ action }', 'after the last file action was undone'), + + // ─── Settings ────────────────────────────────────────────── + onBeforeSettingChange: H('settings', 'intercept', 'settings:write', '{ key, value, previous }', 'before a preference is changed'), + onSettingChange: H('settings', 'observer', 'settings:read', '{ key, value, previous }', 'after a preference changed'), + onSettingsExport: H('settings', 'observer', 'settings:read', '{ keys: string[] }', 'after settings were exported'), + onSettingsImport: H('settings', 'observer', 'settings:read', '{ keys: string[] }', 'after settings were imported'), + onSettingsReset: H('settings', 'observer', 'settings:read', '{}', 'after settings were reset to defaults'), + onSettingsSync: H('settings', 'observer', 'settings:read', '{ direction: "pulled" | "pushed" }', 'after settings were synced with the server'), + onKeywordChange: H('settings', 'observer', 'settings:read', '{ keywords: KeywordView[] }', 'after the tag definitions changed'), + onTrustedSenderChange: H('settings', 'observer', 'settings:read', '{ email, trusted }', 'after a sender was trusted or untrusted for remote content'), + + // ─── Identities ──────────────────────────────────────────── + onIdentitiesLoaded: H('identities', 'observer', 'identity:read', 'IdentityView[]', 'after sender identities were loaded'), + onBeforeIdentityChange: H('identities', 'intercept', 'identity:write', '{ identityId?, change: "create" | "update" | "delete", identity? }', 'before an identity is created, changed or deleted'), + onIdentityCreate: H('identities', 'observer', 'identity:read', 'IdentityView', 'after an identity was created'), + onIdentityUpdate: H('identities', 'observer', 'identity:read', 'IdentityView', 'after an identity was changed'), + onIdentityDelete: H('identities', 'observer', 'identity:read', '{ identityId }', 'after an identity was deleted'), + onIdentitySelect: H('identities', 'observer', 'identity:read', 'IdentityView', 'when another From identity is chosen in the composer'), + onSignatureRender: H('identities', 'transform', 'identity:read', '{ html, text }, { identityId }', 'when a signature is inserted into the composer'), + + // ─── Filters ─────────────────────────────────────────────── + onFiltersLoaded: H('filters', 'observer', 'filters:read', 'FilterRuleView[]', 'after filter rules were loaded'), + onFilterRuleChange: H('filters', 'observer', 'filters:read', '{ ruleId, change: "added" | "updated" | "deleted" | "reordered" | "toggled" }', 'after a filter rule changed in the editor'), + onBeforeFiltersSave: H('filters', 'intercept', 'filters:write', '{ rules: FilterRuleView[] }', 'before filter rules are saved to the server'), + onFiltersSave: H('filters', 'observer', 'filters:read', '{ accountId }', 'after the filter script was saved'), + onSieveScriptChange: H('filters', 'observer', 'filters:read', '{ accountId }', 'after the active script changed'), + onSieveScriptGenerate: H('filters', 'transform', 'filters:write', 'string (Sieve script), SieveScriptGenerateContext', 'before the generated script is uploaded'), + + // ─── Out of office ───────────────────────────────────────── + onVacationLoaded: H('vacation', 'observer', 'vacation:read', 'VacationView', 'after the out-of-office reply was loaded'), + onBeforeVacationUpdate: H('vacation', 'intercept', 'vacation:write', 'VacationView', 'before the out-of-office reply is changed'), + onVacationUpdate: H('vacation', 'observer', 'vacation:read', 'VacationView', 'after the out-of-office reply was changed'), + + // ─── Templates ───────────────────────────────────────────── + onTemplateCreate: H('templates', 'observer', 'templates:read', 'TemplateView', 'after a template was created'), + onTemplateUpdate: H('templates', 'observer', 'templates:read', 'TemplateView', 'after a template was changed'), + onTemplateDelete: H('templates', 'observer', 'templates:read', '{ templateId }', 'after a template was deleted'), + onTemplatesImport: H('templates', 'observer', 'templates:read', '{ count }', 'after templates were imported'), + onTemplateApply: H('templates', 'observer', 'templates:read', 'TemplateView', 'after a template was inserted into the composer'), + onTemplateRender: H('templates', 'transform', 'templates:read', '{ subject, html, text }, { templateId }', 'before a template is inserted; may change the text'), + + // ─── Sign-in and accounts ────────────────────────────────── + onLogin: H('auth', 'observer', 'auth:observe', '{ accountId, username }', 'after a sign-in'), + onAuthReady: H('auth', 'observer', 'auth:observe', '{ accountId }', 'when the session is ready after start or sign-in'), + onTokenRefresh: H('auth', 'observer', 'auth:observe', '{ accountId }', 'after the access token was renewed'), + onAccountAdd: H('auth', 'observer', 'auth:observe', '{ accountId }', 'after another account was added'), + onAccountRemove: H('auth', 'observer', 'auth:observe', '{ accountId }', 'after an account was removed'), + onAccountSwitch: H('auth', 'observer', 'app:lifecycle', '{ accountId, previousAccountId }', 'after another account was made active'), + onBeforeLogout: H('auth', 'observer', 'app:lifecycle', '{ accountId }', 'just before signing out'), + onAfterLogout: H('auth', 'observer', 'app:lifecycle', '{ accountId }', 'after signing out'), + onOAuthCallback: H('auth', 'observer', 'http:fetch', 'OAuthCallbackPayload', 'when a third-party OAuth flow this plugin started returns'), + + // ─── Account security ────────────────────────────────────── + onPasswordChange: H('security', 'observer', 'security:read', '{}', 'after the account password was changed'), + onTotpChange: H('security', 'observer', 'security:read', '{ enabled }', 'after two-factor sign-in was turned on or off'), + onAppPasswordChange: H('security', 'observer', 'security:read', '{ change: "created" | "removed" }', 'after an app password was created or removed'), + onEncryptionChange: H('security', 'observer', 'security:read', '{ enabled }', 'after encryption at rest was changed'), + onDisplayNameChange: H('security', 'observer', 'security:read', '{ displayName }', 'after the account display name was changed'), + + // ─── Navigation ──────────────────────────────────────────── + onNavigate: H('navigation', 'observer', 'ui:observe', 'RouteContext', 'after the app moved to another page'), + onRouteEnter: H('navigation', 'observer', 'ui:observe', 'RouteContext', 'when a page is entered'), + onRouteLeave: H('navigation', 'observer', 'ui:observe', 'RouteContext', 'when a page is left'), + + // ─── Theme and language ──────────────────────────────────── + onThemeChange: H('theme', 'observer', 'ui:observe', '{ theme: "light" | "dark" | "system", resolved: "light" | "dark" }', 'after the colour scheme changed'), + onCustomThemeChange: H('theme', 'observer', 'ui:observe', '{ themeId }', 'after another theme was activated'), + onThemeBeforeApply: H('theme', 'transform', 'ui:observe', 'string (CSS), { themeId, variant }', 'before theme CSS is applied'), + onLocaleChange: H('theme', 'observer', 'ui:observe', '{ locale }', 'after the app language changed'), + + // ─── Notifications ───────────────────────────────────────── + onToastShow: H('notifications', 'observer', 'ui:observe', '{ type, message }', 'when the app shows a toast'), + onToastDismiss: H('notifications', 'observer', 'ui:observe', '{ id }', 'when a toast is dismissed'), + onBrowserNotification: H('notifications', 'transform', 'email:read', '{ title, body, tag } | false, { kind: "mail" | "calendar" | "plugin" }', 'before a system notification is shown; return false to suppress it'), + onNotificationClick: H('notifications', 'observer', 'email:read', '{ tag, data }', 'when a system notification is clicked'), + + // ─── App lifecycle ───────────────────────────────────────── + onAppReady: H('app', 'observer', 'app:lifecycle', '{ accountId }', 'once after the app has started and the mail data is loaded'), + onVisibilityChange: H('app', 'observer', 'app:lifecycle', '{ visible }', 'when the tab is hidden or shown'), + onWindowFocus: H('app', 'observer', 'app:lifecycle', '{}', 'when the window gains focus'), + onWindowBlur: H('app', 'observer', 'app:lifecycle', '{}', 'when the window loses focus'), + onOnline: H('app', 'observer', 'app:lifecycle', '{}', 'when the network comes back'), + onOffline: H('app', 'observer', 'app:lifecycle', '{}', 'when the network goes away'), + onBeforeUnload: H('app', 'observer', 'app:lifecycle', '{}', 'when the page is about to close'), + onAppError: H('app', 'observer', 'app:lifecycle', '{ message }', 'when an unhandled error occurs in the app'), + onInterval: H('app', 'observer', 'app:lifecycle', '{ now }', 'once a minute while the app is open'), + + // ─── Layout ──────────────────────────────────────────────── + onViewChange: H('ui', 'observer', 'ui:observe', '{ view }', 'when the main view changes (list, message, composer)'), + onSidebarToggle: H('ui', 'observer', 'ui:observe', '{ open }', 'when the sidebar is opened or closed'), + onSidebarCollapse: H('ui', 'observer', 'ui:observe', '{ collapsed }', 'when the sidebar is collapsed or expanded'), + onDeviceTypeChange: H('ui', 'observer', 'ui:observe', '{ deviceType: "mobile" | "tablet" | "desktop" }', 'when the layout switches between phone, tablet and desktop'), + onColumnResize: H('ui', 'observer', 'ui:observe', '{ column, width }', 'after a column was resized'), + onMobileBack: H('ui', 'observer', 'ui:observe', '{}', 'when Back is used on the phone layout'), + onTextSelectionChange: H('ui', 'observer', 'email:read', 'SelectionContext', 'when the user selects text (debounced)'), + onDragStart: H('ui', 'observer', 'email:read', '{ kind, ids }', 'when messages or tags start being dragged'), + onDragEnd: H('ui', 'observer', 'email:read', '{ kind, ids, dropped }', 'when a drag ends'), + onEmailDrop: H('ui', 'observer', 'email:read', '{ emailIds, targetMailboxId }', 'after messages were dropped on a folder'), + onTagDrop: H('ui', 'observer', 'email:read', '{ emailIds, keyword }', 'after a tag was dropped on messages'), + onSidebarAppOpen: H('ui', 'observer', 'ui:observe', '{ appId }', 'when a sidebar app is opened'), + onSidebarAppClose: H('ui', 'observer', 'ui:observe', '{ appId }', 'when a sidebar app is closed'), + + // ─── Keyboard ────────────────────────────────────────────── + onBeforeShortcut: H('keyboard', 'intercept', 'ui:keyboard', '{ key, action }', 'before an app shortcut runs; return false to stop it'), + onAfterShortcut: H('keyboard', 'observer', 'ui:keyboard', '{ key, action }', 'after an app shortcut ran'), + + // ─── Inbox tabs ──────────────────────────────────────────── + onTabsChange: H('tabs', 'observer', 'ui:message-list-tabs', 'MessageListTab[]', 'after the set of inbox tabs changed'), + onTabActivate: H('tabs', 'observer', 'ui:message-list-tabs', 'TabActivateContext', 'when another inbox tab is chosen'), + onTabCountsRefresh: H('tabs', 'observer', 'ui:message-list-tabs', 'Record', 'after the unread counts per tab were refreshed'), +} satisfies Record; + +export type HookName = keyof typeof HOOK_CATALOG; + +export function hookSpec(name: string): HookSpec | null { + return Object.prototype.hasOwnProperty.call(HOOK_CATALOG, name) ? HOOK_CATALOG[name as HookName] : null; +} diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 3c6c48dcf..af19b455f 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -322,6 +322,7 @@ export const emailHooks = { // Return false to cancel opening the composer. onBeforeCompose: new HookBus(), onComposerOpen: new HookBus(), + onComposerClose: new HookBus(), // Fails closed with the long send budget: a guard that asks the user and // gets no answer must not let the mail go out behind its dialog. onBeforeEmailSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS, { failClosed: true }), @@ -338,25 +339,35 @@ export const emailHooks = { onAfterEmailDelete: new HookBus(), onBeforeEmailMove: new HookBus(), onAfterEmailMove: new HookBus(), + onBeforeEmailArchive: new HookBus(), // Fired after one or more emails are archived to the Archive mailbox onEmailArchive: new HookBus(), // Fired after one or more emails are moved out of the Archive mailbox onEmailUnarchive: new HookBus(), + onBeforeEmailReadStateChange: new HookBus(), onEmailReadStateChange: new HookBus(), + onBeforeEmailStarToggle: new HookBus(), onEmailStarToggle: new HookBus(), + onBeforeEmailSpamToggle: new HookBus(), onEmailSpamToggle: new HookBus(), + onBeforeEmailKeywordChange: new HookBus(), onEmailKeywordChange: new HookBus(), onMailboxChange: new HookBus(), onMailboxesRefresh: new HookBus(), + onBeforeMailboxCreate: new HookBus(), onMailboxCreate: new HookBus(), + onBeforeMailboxRename: new HookBus(), onMailboxRename: new HookBus(), + onBeforeMailboxDelete: new HookBus(), onMailboxDelete: new HookBus(), + onBeforeMailboxEmpty: new HookBus(), onMailboxEmpty: new HookBus(), onSearch: new HookBus(), onSearchResults: new HookBus(), onEmailSelectionChange: new HookBus(), onNewEmailReceived: new HookBus(), onPushConnectionChange: new HookBus(), + onSyncStateChange: new HookBus(), onQuotaChange: new HookBus(), // Intercept hook - fired when a mailto: link is clicked. // Return false to prevent the browser from opening the system mail client. @@ -447,7 +458,9 @@ export const calendarHooks = { onAfterEventUpdate: new HookBus(), onBeforeEventDelete: new HookBus(), onAfterEventDelete: new HookBus(), + onBeforeEventRsvp: new HookBus(), onEventRsvp: new HookBus(), + onBeforeEventsImport: new HookBus(), onEventsImport: new HookBus(), onCalendarDateChange: new HookBus(), onCalendarViewChange: new HookBus(), @@ -525,6 +538,7 @@ export const fileHooks = { // Return false to cancel the rename. onBeforeFileRename: new HookBus(), onFileRename: new HookBus(), + onBeforeFileMove: new HookBus(), onFileMove: new HookBus(), onFileCopy: new HookBus(), onFileDuplicate: new HookBus(), @@ -556,6 +570,7 @@ export const authHooks = { // §7.6 Settings Hooks export const settingsHooks = { + onBeforeSettingChange: new HookBus(), onSettingChange: new HookBus(), onSettingsExport: new HookBus(), onSettingsImport: new HookBus(), @@ -568,6 +583,7 @@ export const settingsHooks = { // §7.7 Identity Hooks export const identityHooks = { onIdentitiesLoaded: new HookBus(), + onBeforeIdentityChange: new HookBus(), onIdentityCreate: new HookBus(), onIdentityUpdate: new HookBus(), onIdentityDelete: new HookBus(), @@ -579,6 +595,7 @@ export const identityHooks = { export const filterHooks = { onFiltersLoaded: new HookBus(), onFilterRuleChange: new HookBus(), + onBeforeFiltersSave: new HookBus(), // Observer fired after the host successfully uploads the account's active // Sieve script (visual-builder save or plugin-triggered regenerate). onFiltersSave: new HookBus(), @@ -600,8 +617,11 @@ export const filterHooks = { // §7.9 Task Hooks export const taskHooks = { onTasksLoaded: new HookBus(), + onBeforeTaskCreate: new HookBus(), onTaskCreate: new HookBus(), + onBeforeTaskUpdate: new HookBus(), onTaskUpdate: new HookBus(), + onBeforeTaskDelete: new HookBus(), onTaskDelete: new HookBus(), onTaskToggleComplete: new HookBus(), onTaskFilterChange: new HookBus(), @@ -617,17 +637,10 @@ export const templateHooks = { onTemplateRender: new HookBus(), }; -// §7.11 S/MIME Hooks -export const smimeHooks = { - onSmimeKeyImport: new HookBus(), - onSmimeCertImport: new HookBus(), - onSmimeKeyStateChange: new HookBus(), - onSmimeDefaultsChange: new HookBus(), -}; - // §7.12 Vacation Hooks export const vacationHooks = { onVacationLoaded: new HookBus(), + onBeforeVacationUpdate: new HookBus(), onVacationUpdate: new HookBus(), }; @@ -639,7 +652,6 @@ export const uiHooks = { onDeviceTypeChange: new HookBus(), onColumnResize: new HookBus(), onMobileBack: new HookBus(), - onMobileViewSwitch: new HookBus(), // Intercept hook - fires when the user clicks an external link inside the // app (typically inside an email body iframe). Handler receives // ExternalLinkContext; return false to cancel the navigation. Mutate @@ -690,7 +702,6 @@ export const dragDropHooks = { // §7.17 Keyboard Hooks export const keyboardHooks = { - registerShortcut: new HookBus(), onBeforeShortcut: new HookBus(), onAfterShortcut: new HookBus(), }; @@ -725,7 +736,6 @@ export const accountSecurityHooks = { export const sidebarAppHooks = { onSidebarAppOpen: new HookBus(), onSidebarAppClose: new HookBus(), - onSidebarAppChange: new HookBus(), }; // §7.21 Avatar Hooks @@ -799,7 +809,7 @@ export const messageListTabHooks = { const allHookGroups = [ emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, - taskHooks, templateHooks, smimeHooks, vacationHooks, + taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, messageListTabHooks, diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index b06bfde91..791308b74 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -6,7 +6,7 @@ import { pluginStorage } from '../plugin-storage'; import { emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, - taskHooks, templateHooks, smimeHooks, vacationHooks, + taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, @@ -30,7 +30,7 @@ type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => un const HOOK_BUSES: Record = Object.assign({}, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, - taskHooks, templateHooks, smimeHooks, vacationHooks, + taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index a0ddfa85d..5bad2b274 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -1,5 +1,6 @@ import type { InstalledPlugin, Permission, SlotName } from '../plugin-types'; import { IMPLICIT_PERMISSIONS } from '../plugin-types'; +import { hookSpec } from '../plugin-hook-catalog'; /** * Whether `plugin` may use `perm`: the manifest must declare it, and an @@ -18,239 +19,14 @@ export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission): // ─── Hooks ──────────────────────────────────────────────────── -/** - * The permission each hook needs. A hook hands the plugin data (an opened - * message, the outgoing mail) or lets it change what the host does with it, - * so registering one is as much an API call as `email.get` and is gated the - * same way. A hook missing from this table is refused. - */ -const HOOK_PERMISSIONS: Record = { - // Email: reading - onEmailOpen: 'email:read', - onEmailClose: 'email:read', - onEmailContentRender: 'email:read', - onThreadExpand: 'email:read', - onComposerOpen: 'email:read', - onAfterEmailSend: 'email:read', - onDraftAutoSave: 'email:read', - onAfterEmailDelete: 'email:read', - onAfterEmailMove: 'email:read', - onEmailArchive: 'email:read', - onEmailUnarchive: 'email:read', - onEmailReadStateChange: 'email:read', - onEmailStarToggle: 'email:read', - onEmailSpamToggle: 'email:read', - onEmailKeywordChange: 'email:read', - onMailboxChange: 'email:read', - onMailboxesRefresh: 'email:read', - onMailboxCreate: 'email:read', - onMailboxRename: 'email:read', - onMailboxDelete: 'email:read', - onMailboxEmpty: 'email:read', - onSearch: 'email:read', - onSearchResults: 'email:read', - onEmailSelectionChange: 'email:read', - onNewEmailReceived: 'email:read', - onPushConnectionChange: 'email:read', - onQuotaChange: 'email:read', - onMailtoIntercept: 'email:read', - onBeforeReply: 'email:read', - onBeforeReplyAll: 'email:read', - onBeforeForward: 'email:read', - onBeforeAttachmentUpload: 'email:read', - onAfterAttachmentUpload: 'email:read', - onAttachmentDownload: 'email:read', - onProvideSearchResults: 'email:read', - onDraftChange: 'email:read', - // Email: changing mail in the mailbox - onBeforeEmailDelete: 'email:write', - onBeforeEmailMove: 'email:write', - // Email: changing what is sent (recipients, body, drafts, the submission) - onBeforeCompose: 'email:send', - onBeforeEmailSend: 'email:send', - onTransformOutgoingEmail: 'email:send', - onComposeSend: 'email:send', - onBeforeDraftAutoSave: 'email:send', - onBeforeEditDraft: 'email:send', - onBuildQuoteHeader: 'email:send', - onRecipientChipsChange: 'email:send', - onBeforeComposeOpenToForwardAsAttachment: 'email:send', - onBeforeComposeOpenToReply: 'email:send', - onBeforeComposeOpenToReplyAll: 'email:send', - onBeforeComposeOpenToForward: 'email:send', - onBeforeBlobUpload: 'email:blob-write', - // Email: changing what is displayed - onEmailsFetched: 'email:render-takeover', - onAttachmentPreview: 'email:render-takeover', - onEmailListItemRender: 'email:render-takeover', - onRenderEmailBody: 'email:render-takeover', - // Email data reached through other surfaces - onAvatarResolve: 'email:read', - onDragStart: 'email:read', - onDragEnd: 'email:read', - onEmailDrop: 'email:read', - onTagDrop: 'email:read', - onBrowserNotification: 'email:read', - onNotificationClick: 'email:read', - onTextSelectionChange: 'email:read', - onBeforeEmailCategorize: 'email:read', - onEmailCategorize: 'email:read', - - // Calendar - onCalendarEventOpen: 'calendar:read', - onAfterEventCreate: 'calendar:read', - onAfterEventUpdate: 'calendar:read', - onAfterEventDelete: 'calendar:read', - onEventRsvp: 'calendar:read', - onEventsImport: 'calendar:read', - onCalendarDateChange: 'calendar:read', - onCalendarViewChange: 'calendar:read', - onCalendarChange: 'calendar:read', - onCalendarVisibilityToggle: 'calendar:read', - onICalSubscriptionChange: 'calendar:read', - onCalendarAlert: 'calendar:read', - onCalendarAlertAcknowledge: 'calendar:read', - onCheckEventConflicts: 'calendar:read', - onCalendarEventFormOpen: 'calendar:read', - onBeforeEventCreate: 'calendar:write', - onBeforeEventUpdate: 'calendar:write', - onBeforeEventDelete: 'calendar:write', - onCalendarEventFormSave: 'calendar:write', - - // Contacts - onContactOpen: 'contacts:read', - onAfterContactCreate: 'contacts:read', - onAfterContactUpdate: 'contacts:read', - onAfterContactDelete: 'contacts:read', - onContactsImport: 'contacts:read', - onContactSelectionChange: 'contacts:read', - onContactGroupChange: 'contacts:read', - onContactGroupMemberChange: 'contacts:read', - onContactMove: 'contacts:read', - onProvideRecipientSuggestions: 'contacts:read', - onBeforeContactCreate: 'contacts:write', - onBeforeContactUpdate: 'contacts:write', - onBeforeContactDelete: 'contacts:write', - - // Files - onFileNavigate: 'files:read', - onAfterFileUpload: 'files:read', - onFileDownload: 'files:read', - onFileUploadCancel: 'files:read', - onDirectoryCreate: 'files:read', - onAfterFileDelete: 'files:read', - onFileRename: 'files:read', - onFileMove: 'files:read', - onFileCopy: 'files:read', - onFileDuplicate: 'files:read', - onFileFavoriteToggle: 'files:read', - onFileSelectionChange: 'files:read', - onFileUndo: 'files:read', - onBeforeFileUpload: 'files:write', - onBeforeFileDelete: 'files:write', - onBeforeFileRename: 'files:write', - - // Sign-in state. Logout and account switches are lifecycle events every - // plugin needs to drop per-account state, so they stay implicit. - onLogin: 'auth:observe', - onAccountAdd: 'auth:observe', - onAccountRemove: 'auth:observe', - onTokenRefresh: 'auth:observe', - onAuthReady: 'auth:observe', - onBeforeLogout: 'app:lifecycle', - onAfterLogout: 'app:lifecycle', - onAccountSwitch: 'app:lifecycle', - // Carries the code of a plugin's own OAuth flow with a third party. - onOAuthCallback: 'http:fetch', - - // Settings - onSettingChange: 'settings:read', - onSettingsExport: 'settings:read', - onSettingsReset: 'settings:read', - onSettingsSync: 'settings:read', - onKeywordChange: 'settings:read', - onTrustedSenderChange: 'settings:read', - onSettingsImport: 'settings:write', - - // Identities - onIdentitiesLoaded: 'identity:read', - onIdentityCreate: 'identity:read', - onIdentityUpdate: 'identity:read', - onIdentityDelete: 'identity:read', - onIdentitySelect: 'identity:read', - onSignatureRender: 'identity:read', - - // Filters - onFiltersLoaded: 'filters:read', - onFilterRuleChange: 'filters:read', - onFiltersSave: 'filters:read', - onSieveScriptChange: 'filters:read', - onSieveScriptGenerate: 'filters:write', - - // Tasks, templates, S/MIME, vacation, account security - onTasksLoaded: 'tasks:read', - onTaskCreate: 'tasks:read', - onTaskUpdate: 'tasks:read', - onTaskDelete: 'tasks:read', - onTaskToggleComplete: 'tasks:read', - onTaskFilterChange: 'tasks:read', - onTemplateCreate: 'templates:read', - onTemplateUpdate: 'templates:read', - onTemplateDelete: 'templates:read', - onTemplateApply: 'templates:read', - onTemplatesImport: 'templates:read', - onTemplateRender: 'templates:read', - onSmimeKeyImport: 'smime:read', - onSmimeCertImport: 'smime:read', - onSmimeKeyStateChange: 'smime:read', - onSmimeDefaultsChange: 'smime:read', - onVacationLoaded: 'vacation:read', - onVacationUpdate: 'vacation:read', - onPasswordChange: 'security:read', - onTotpChange: 'security:read', - onAppPasswordChange: 'security:read', - onEncryptionChange: 'security:read', - onDisplayNameChange: 'security:read', - - // UI and app state - onViewChange: 'ui:observe', - onSidebarToggle: 'ui:observe', - onSidebarCollapse: 'ui:observe', - onDeviceTypeChange: 'ui:observe', - onColumnResize: 'ui:observe', - onMobileBack: 'ui:observe', - onMobileViewSwitch: 'ui:observe', - onBeforeExternalLink: 'ui:observe', - onThemeChange: 'ui:observe', - onCustomThemeChange: 'ui:observe', - onLocaleChange: 'ui:observe', - onThemeBeforeApply: 'ui:observe', - onToastShow: 'ui:observe', - onToastDismiss: 'ui:observe', - onSidebarAppOpen: 'ui:observe', - onSidebarAppClose: 'ui:observe', - onSidebarAppChange: 'ui:observe', - onNavigate: 'ui:observe', - onRouteEnter: 'ui:observe', - onRouteLeave: 'ui:observe', - onTabsChange: 'ui:message-list-tabs', - onTabActivate: 'ui:message-list-tabs', - onTabCountsRefresh: 'ui:message-list-tabs', - onBeforeShortcut: 'ui:keyboard', - onAfterShortcut: 'ui:keyboard', - onAppReady: 'app:lifecycle', - onVisibilityChange: 'app:lifecycle', - onBeforeUnload: 'app:lifecycle', - onAppError: 'app:lifecycle', - onInterval: 'app:lifecycle', - onWindowFocus: 'app:lifecycle', - onWindowBlur: 'app:lifecycle', - onOnline: 'app:lifecycle', - onOffline: 'app:lifecycle', -}; +// The permission each hook needs comes from the hook catalogue +// (lib/plugin-hook-catalog.ts). A hook hands the plugin data (an opened +// message, the outgoing mail) or lets it change what the host does with it, +// so registering one is as much an API call as `email.get` and is gated the +// same way. A hook missing from the catalogue is refused. export function hookPermission(hookName: string): Permission | null { - return Object.prototype.hasOwnProperty.call(HOOK_PERMISSIONS, hookName) ? HOOK_PERMISSIONS[hookName] : null; + return hookSpec(hookName)?.permission ?? null; } export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): boolean { diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 57adaa37a..08f0949e7 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1072,7 +1072,6 @@ export const ALL_PERMISSIONS = [ 'filters:read', 'filters:write', 'tasks:read', 'tasks:write', 'templates:read', 'templates:write', - 'smime:read', 'vacation:read', 'vacation:write', 'settings:read', 'settings:write', 'security:read', @@ -1095,6 +1094,14 @@ export const ALL_PERMISSIONS = [ export type Permission = (typeof ALL_PERMISSIONS)[number]; +/** + * Permissions older plugins may still name that no longer grant anything: + * `smime:read` covered the built-in S/MIME code, which moved into a plugin. + * Accepted and dropped at install, with a warning, so those plugins still + * install. + */ +export const DEPRECATED_PERMISSIONS: readonly string[] = ['smime:read']; + /** Permissions always granted regardless of manifest */ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle']; From af53675191756ee324eb09bde1235250264b8d6e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:16:25 +0200 Subject: [PATCH 032/147] feat: add plain-data views of messages, events, contacts, tasks and files for plugin hooks --- lib/__tests__/plugin-projection.test.ts | 38 ++++++ lib/plugin-projection.ts | 150 +++++++++++++++++++++++- lib/plugin-types.ts | 53 ++++++++- 3 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 lib/__tests__/plugin-projection.test.ts diff --git a/lib/__tests__/plugin-projection.test.ts b/lib/__tests__/plugin-projection.test.ts new file mode 100644 index 000000000..abae6ccbf --- /dev/null +++ b/lib/__tests__/plugin-projection.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { contactToView, emailSummary, eventToView, taskToView } from '../plugin-projection'; +import type { CalendarEvent, CalendarTask, ContactCard, Email } from '../jmap/types'; + +describe('plugin projections', () => { + it('writes contact dates without a year as --MM-DD', () => { + const view = contactToView({ + id: 'c1', addressBookIds: { ab: true }, + name: { components: [{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'Silva' }] }, + anniversaries: { + a: { kind: 'birth', date: { '@type': 'PartialDate', month: 2, day: 29 } as never }, + b: { kind: 'wedding', date: { '@type': 'PartialDate', year: 2010, month: 6, day: 5 } as never }, + }, + } as unknown as ContactCard); + + expect(view.fullName).toBe('Ana Silva'); + expect(view.anniversaries).toEqual([ + { kind: 'birth', date: '--02-29' }, + { kind: 'wedding', date: '2010-06-05' }, + ]); + }); + + it('leaves the body out of an email summary', () => { + const summary = emailSummary({ + id: 'm1', threadId: 't', mailboxIds: { inbox: true, old: false }, keywords: { $seen: true }, + from: [{ email: 'a@x' }], to: [], subject: 'S', receivedAt: 'r', preview: 'p', hasAttachment: false, size: 9, + bodyValues: { 1: { value: 'secret body' } }, + } as unknown as Email); + + expect(summary.mailboxIds).toEqual(['inbox']); + expect(JSON.stringify(summary)).not.toContain('secret body'); + }); + + it('marks recurring events and completed tasks', () => { + expect(eventToView({ id: 'e', uid: 'u', calendarIds: { c: true }, recurrenceRules: [{ frequency: 'yearly' }] } as unknown as CalendarEvent).isRecurring).toBe(true); + expect(taskToView({ id: 't', uid: 'u', calendarIds: {}, progress: 'completed' } as unknown as CalendarTask).isComplete).toBe(true); + }); +}); diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index 865df43e3..923758fe3 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -2,8 +2,14 @@ // that plugins consume. Keeping this in one place ensures every slot/hook // hands plugins the same shape declared in plugin-types.ts. -import type { Email } from '@/lib/jmap/types'; -import type { EmailReadView } from '@/lib/plugin-types'; +import type { + CalendarEvent, CalendarTask, ContactCard, Email, FileNode, Identity, VacationResponse, +} from '@/lib/jmap/types'; +import type { + CalendarEventView, ContactView, EmailReadView, EmailSummary, FileResourceView, IdentityView, + TaskView, TemplateView, VacationView, +} from '@/lib/plugin-types'; +import type { EmailTemplate } from '@/lib/template-types'; import { generateEmailSource } from '@/lib/email-source'; // Resolve a message's plain-text body from its JMAP body parts. Plugins that @@ -61,3 +67,143 @@ export function emailToReadView(email: Email): EmailReadView { auth: email.authenticationResults, }; } + +const trueKeys = (rec?: Record | null): string[] => + Object.keys(rec || {}).filter((k) => rec?.[k]); + +/** Headers and flags of a message, without the body (see EmailSummary). */ +export function emailSummary(email: Email): EmailSummary { + return { + id: email.id, + threadId: email.threadId, + mailboxIds: trueKeys(email.mailboxIds), + from: (email.from || []).map((a) => ({ name: a.name || '', email: a.email })), + to: (email.to || []).map((a) => ({ name: a.name || '', email: a.email })), + subject: email.subject || '', + receivedAt: email.receivedAt, + preview: email.preview || '', + keywords: trueKeys(email.keywords), + hasAttachment: !!email.hasAttachment, + size: email.size || 0, + }; +} + +export function eventToView(event: CalendarEvent): CalendarEventView { + const location = Object.values(event.locations || {})[0]?.name || ''; + return { + id: event.id, + uid: event.uid, + calendarId: trueKeys(event.calendarIds)[0] || '', + title: event.title || '', + description: event.description || '', + start: event.start, + duration: event.duration, + timeZone: event.timeZone, + utcStart: event.utcStart, + utcEnd: event.utcEnd, + isAllDay: !!event.showWithoutTime, + location, + status: event.status, + participants: Object.values(event.participants || {}).map((p) => ({ + name: p.name || '', + email: p.email || '', + role: trueKeys((p as { roles?: Record }).roles).join(','), + status: (p as { participationStatus?: string }).participationStatus || '', + })), + isRecurring: !!(event.recurrenceRules && event.recurrenceRules.length > 0), + keywords: trueKeys(event.keywords), + }; +} + +function anniversaryDate(value: unknown): string { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + const v = value as { year?: number; month?: number; day?: number; utc?: string }; + if (typeof v.utc === 'string') return v.utc.slice(0, 10); + const mm = String(v.month ?? 0).padStart(2, '0'); + const dd = String(v.day ?? 0).padStart(2, '0'); + return v.year ? `${String(v.year).padStart(4, '0')}-${mm}-${dd}` : `--${mm}-${dd}`; + } + return ''; +} + +export function contactToView(contact: ContactCard): ContactView { + const components = contact.name?.components || []; + const part = (kind: string) => components.filter((c) => c.kind === kind).map((c) => c.value).join(' '); + return { + id: contact.id, + uid: contact.uid || '', + addressBookId: trueKeys(contact.addressBookIds)[0] || '', + kind: contact.kind || 'individual', + fullName: contact.name?.full || [part('given'), part('surname')].filter(Boolean).join(' '), + firstName: part('given'), + lastName: part('surname'), + emails: Object.values(contact.emails || {}).map((e) => e.address).filter(Boolean), + phones: Object.values(contact.phones || {}).map((p) => p.number).filter(Boolean), + company: Object.values(contact.organizations || {})[0]?.name || '', + notes: Object.values(contact.notes || {}).map((n) => (n as { note?: string }).note || '').join('\n'), + anniversaries: Object.values(contact.anniversaries || {}).map((a) => ({ kind: a.kind, date: anniversaryDate(a.date) })), + }; +} + +export function taskToView(task: CalendarTask): TaskView { + return { + id: task.id, + uid: task.uid, + title: task.title || '', + description: task.description || '', + isComplete: task.progress === 'completed', + progress: task.progress, + dueDate: task.due, + priority: task.priority ?? 0, + calendarId: trueKeys(task.calendarIds)[0] || '', + keywords: trueKeys(task.keywords), + }; +} + +export function fileToView(node: FileNode, path = ''): FileResourceView { + const isDir = node.type === 'd' || node.blobId === null; + return { + id: node.id, + name: node.name, + type: isDir ? 'directory' : 'file', + size: node.size || 0, + mimeType: isDir ? '' : node.type, + path, + modified: node.modified, + }; +} + +export function identityToView(identity: Identity): IdentityView { + return { + id: identity.id, + name: identity.name || '', + email: identity.email, + replyTo: identity.replyTo?.map((a) => a.email).join(', ') || null, + bcc: identity.bcc?.map((a) => a.email).join(', ') || null, + htmlSignature: identity.htmlSignature || '', + textSignature: identity.textSignature || '', + }; +} + +export function templateToView(template: EmailTemplate): TemplateView { + return { + id: template.id, + name: template.name, + category: template.category, + subject: template.subject || '', + body: template.body || '', + isHTML: !!template.isHTML, + }; +} + +export function vacationToView(vacation: VacationResponse): VacationView { + return { + isEnabled: vacation.isEnabled, + subject: vacation.subject || '', + htmlBody: vacation.htmlBody || '', + textBody: vacation.textBody || '', + fromDate: vacation.fromDate, + toDate: vacation.toDate, + }; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 08f0949e7..2cb987675 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -451,6 +451,25 @@ export interface EmailReadView { }; } +/** + * The light view of a message used by list-level hooks (new mail, selection, + * moves): headers and flags, no body or source. Same email:read sensitivity + * as EmailReadView. + */ +export interface EmailSummary { + id: string; + threadId: string; + mailboxIds: string[]; + from: { name: string; email: string }[]; + to: { name: string; email: string }[]; + subject: string; + receivedAt: string; + preview: string; + keywords: string[]; + hasAttachment: boolean; + size: number; +} + export interface DraftView { to: string[]; cc: string[]; @@ -474,15 +493,26 @@ export interface MailboxView { export interface CalendarEventView { id: string; + /** JSCalendar uid, stable across edits and the same for every occurrence. */ + uid: string; calendarId: string; title: string; description: string; + /** Local start, as stored (ISO 8601 without zone, see `timeZone`). */ start: string; - end: string; + /** ISO 8601 duration, e.g. PT1H. */ + duration: string; + timeZone: string | null; + /** UTC start and end when the server computed them. */ + utcStart: string | null; + utcEnd: string | null; isAllDay: boolean; location: string; status: string; - recurrenceRule?: string; + /** Attendee addresses, organizer included. */ + participants: { name: string; email: string; role: string; status: string }[]; + isRecurring: boolean; + keywords: string[]; } export interface CalendarView { @@ -495,13 +525,21 @@ export interface CalendarView { export interface ContactView { id: string; + uid: string; addressBookId: string; + kind: string; + fullName: string; firstName: string; lastName: string; emails: string[]; phones: string[]; company: string; notes: string; + /** + * Birthdays, anniversaries and other dates, as `--MM-DD` when the year is + * unknown or `YYYY-MM-DD` when it is known. + */ + anniversaries: { kind: string; date: string }[]; } export interface AddressBookView { @@ -538,20 +576,25 @@ export interface IdentityView { export interface TaskView { id: string; + uid: string; title: string; description: string; isComplete: boolean; + progress: string; dueDate: string | null; - priority: string; + /** 0 (none) or 1 (highest) to 9 (lowest), as in JSCalendar. */ + priority: number; calendarId: string; + keywords: string[]; } export interface TemplateView { id: string; name: string; + category: string; subject: string; - htmlBody: string; - textBody: string; + body: string; + isHTML: boolean; } export interface FilterRuleView { From 606861eb71d9921cdfb790726270306b1d480f77 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:19:54 +0200 Subject: [PATCH 033/147] feat: tell plugins about the app's lifecycle and navigation on every page, not only in mail --- app/(main)/[locale]/layout.tsx | 2 + .../__tests__/plugin-app-events.test.tsx | 86 ++++++++++++ components/mail/mail-app.tsx | 86 +----------- components/providers/plugin-app-events.tsx | 131 ++++++++++++++++++ lib/__tests__/plugin-hook-catalog.test.ts | 2 +- 5 files changed, 223 insertions(+), 84 deletions(-) create mode 100644 components/__tests__/plugin-app-events.test.tsx create mode 100644 components/providers/plugin-app-events.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 5e6f64cac..e3bca2f0b 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { ImpersonationReconciler } from "@/components/impersonation/impersonatio import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; +import { PluginAppEvents } from "@/components/providers/plugin-app-events"; import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -59,6 +60,7 @@ export default async function LocaleLayout({ + diff --git a/components/__tests__/plugin-app-events.test.tsx b/components/__tests__/plugin-app-events.test.tsx new file mode 100644 index 000000000..6faf67ff3 --- /dev/null +++ b/components/__tests__/plugin-app-events.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render } from '@testing-library/react'; +import { appLifecycleHooks, clearAllHooks, routerHooks } from '@/lib/plugin-hooks'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useAuthStore } from '@/stores/auth-store'; + +let pathname = '/'; +vi.mock('next/navigation', () => ({ usePathname: () => pathname })); + +import { PluginAppEvents, PLUGIN_INTERVAL_MS } from '../providers/plugin-app-events'; + +function listen unknown) => unknown }>(bus: T) { + const fn = vi.fn(); + bus.register('test', fn); + return fn; +} + +beforeEach(() => { + clearAllHooks(); + pathname = '/'; + usePluginStore.setState({ initialized: false }); + useAuthStore.setState({ isAuthenticated: false }); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('app events for plugins', () => { + // These used to live in the mail view and stopped on every other page. + it('reports the network and tab state on any page', () => { + const online = listen(appLifecycleHooks.onOnline); + const visibility = listen(appLifecycleHooks.onVisibilityChange); + render(); + + act(() => { window.dispatchEvent(new Event('online')); }); + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + + expect(online).toHaveBeenCalledTimes(1); + expect(visibility).toHaveBeenCalledWith({ visible: true }); + }); + + it('reports unhandled errors', () => { + const errors = listen(appLifecycleHooks.onAppError); + render(); + + act(() => { window.dispatchEvent(new ErrorEvent('error', { message: 'boom' })); }); + + expect(errors).toHaveBeenCalledWith({ message: 'boom' }); + }); + + it('ticks once a minute', () => { + vi.useFakeTimers(); + const tick = listen(appLifecycleHooks.onInterval); + render(); + + act(() => { vi.advanceTimersByTime(PLUGIN_INTERVAL_MS * 2 + 10); }); + + expect(tick).toHaveBeenCalledTimes(2); + }); + + it('says the app is ready once, after sign-in and plugin start', () => { + const ready = listen(appLifecycleHooks.onAppReady); + const { rerender } = render(); + expect(ready).not.toHaveBeenCalled(); + + act(() => { + useAuthStore.setState({ isAuthenticated: true }); + usePluginStore.setState({ initialized: true }); + }); + rerender(); + act(() => { usePluginStore.setState({ initialized: false }); }); + act(() => { usePluginStore.setState({ initialized: true }); }); + + expect(ready).toHaveBeenCalledTimes(1); + }); + + it('reports navigation between pages', () => { + const navigate = listen(routerHooks.onNavigate); + const { rerender } = render(); + + pathname = '/calendar'; + rerender(); + + expect(navigate).toHaveBeenCalledWith({ path: '/calendar', from: '/' }); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 385bbbdaa..4eca088ca 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useLayoutEffect, useState, useRef, useMemo, useCallback, useId } from "react"; -import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; @@ -116,7 +115,7 @@ import { useThemeStore } from "@/stores/theme-store"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils"; -import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; +import { emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix"; @@ -293,87 +292,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => clearInterval(timer); }, [isRateLimited, rateLimitUntil]); - // Plugin hooks: window-level lifecycle + selection + service-worker messages. - // One effect because the listeners share a registration / cleanup window. - useEffect(() => { - if (typeof window === 'undefined') return; - const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); }; - const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); }; - const onOnline = () => { appLifecycleHooks.onOnline.emit(); }; - const onOffline = () => { appLifecycleHooks.onOffline.emit(); }; - - let selectionTimer: ReturnType | null = null; - const onSelectionChange = () => { - if (selectionTimer) clearTimeout(selectionTimer); - selectionTimer = setTimeout(() => { - const sel = document.getSelection(); - const text = sel?.toString() ?? ''; - if (!text) return; - const anchorNode = sel?.anchorNode as Node | null; - const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE - ? anchorNode as Element - : anchorNode?.parentElement) ?? null; - let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other'; - let emailId: string | undefined; - if (anchorEl) { - if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) { - source = 'email-body'; - const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null; - emailId = idEl?.dataset.emailId; - } else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) { - source = 'composer'; - } else if (anchorEl.closest('[data-plugin-source="task-detail"]')) { - source = 'task-detail'; - } else if (anchorEl.closest('[data-plugin-source="event-detail"]')) { - source = 'event-detail'; - } - } - uiHooks.onTextSelectionChange.emit({ text, source, emailId }); - }, 150); - }; - - const onSwMessage = (e: MessageEvent) => { - const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null; - if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') { - toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data }); - } - }; - - window.addEventListener('focus', onFocus); - window.addEventListener('blur', onBlur); - window.addEventListener('online', onOnline); - window.addEventListener('offline', onOffline); - document.addEventListener('selectionchange', onSelectionChange); - if (typeof navigator !== 'undefined' && navigator.serviceWorker) { - navigator.serviceWorker.addEventListener('message', onSwMessage); - } - return () => { - window.removeEventListener('focus', onFocus); - window.removeEventListener('blur', onBlur); - window.removeEventListener('online', onOnline); - window.removeEventListener('offline', onOffline); - document.removeEventListener('selectionchange', onSelectionChange); - if (selectionTimer) clearTimeout(selectionTimer); - if (typeof navigator !== 'undefined' && navigator.serviceWorker) { - navigator.serviceWorker.removeEventListener('message', onSwMessage); - } - }; - }, []); - - // Plugin hooks: route navigation. Tracks Next.js pathname transitions. - const pathname = usePathname(); - const prevPathnameRef = useRef(null); - useEffect(() => { - if (!pathname) return; - const from = prevPathnameRef.current; - if (from === pathname) return; - if (from !== null) { - routerHooks.onRouteLeave.emit({ path: from }); - routerHooks.onNavigate.emit({ path: pathname, from }); - } - routerHooks.onRouteEnter.emit({ path: pathname }); - prevPathnameRef.current = pathname; - }, [pathname]); + // Window, selection and navigation events for plugins are fired app-wide + // by components/providers/plugin-app-events.tsx. // Mobile/tablet responsive hooks const { isMobile, isTablet } = useDeviceDetection(); diff --git a/components/providers/plugin-app-events.tsx b/components/providers/plugin-app-events.tsx new file mode 100644 index 000000000..b0aff57dc --- /dev/null +++ b/components/providers/plugin-app-events.tsx @@ -0,0 +1,131 @@ +'use client'; + +// Window, navigation and app lifecycle events for plugins. Mounted once in +// the localized layout, so plugins hear about them on every page; they used +// to be wired inside the mail view and stopped in Calendar, Contacts, Files +// and Settings. + +import { useEffect, useRef } from 'react'; +import { usePathname } from 'next/navigation'; +import { appLifecycleHooks, routerHooks, toastHooks, uiHooks } from '@/lib/plugin-hooks'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; + +/** How often onInterval fires. */ +export const PLUGIN_INTERVAL_MS = 60_000; + +type SelectionSource = 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other'; + +function selectionSource(anchorEl: Element | null): { source: SelectionSource; emailId?: string } { + if (!anchorEl) return { source: 'other' }; + if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) { + const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null; + return { source: 'email-body', emailId: idEl?.dataset.emailId }; + } + if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) return { source: 'composer' }; + if (anchorEl.closest('[data-plugin-source="task-detail"]')) return { source: 'task-detail' }; + if (anchorEl.closest('[data-plugin-source="event-detail"]')) return { source: 'event-detail' }; + return { source: 'other' }; +} + +export function PluginAppEvents() { + // Window and document events. + useEffect(() => { + if (typeof window === 'undefined') return; + const onFocus = () => { void appLifecycleHooks.onWindowFocus.emit({}); }; + const onBlur = () => { void appLifecycleHooks.onWindowBlur.emit({}); }; + const onOnline = () => { void appLifecycleHooks.onOnline.emit({}); }; + const onOffline = () => { void appLifecycleHooks.onOffline.emit({}); }; + const onVisibility = () => { + void appLifecycleHooks.onVisibilityChange.emit({ visible: document.visibilityState === 'visible' }); + }; + // Synchronous: the page may be gone before an async handler runs. + const onBeforeUnload = () => { appLifecycleHooks.onBeforeUnload.emitSync({}); }; + const onError = (event: ErrorEvent) => { + void appLifecycleHooks.onAppError.emit({ message: String(event.message || 'Unknown error').slice(0, 500) }); + }; + const onRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason instanceof Error ? event.reason.message : String(event.reason ?? 'Unhandled rejection'); + void appLifecycleHooks.onAppError.emit({ message: reason.slice(0, 500) }); + }; + + let selectionTimer: ReturnType | null = null; + const onSelectionChange = () => { + if (selectionTimer) clearTimeout(selectionTimer); + selectionTimer = setTimeout(() => { + if (uiHooks.onTextSelectionChange.size === 0) return; + const sel = document.getSelection(); + const text = sel?.toString() ?? ''; + if (!text) return; + const anchorNode = sel?.anchorNode as Node | null; + const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE + ? anchorNode as Element + : anchorNode?.parentElement) ?? null; + void uiHooks.onTextSelectionChange.emit({ text, ...selectionSource(anchorEl) }); + }, 150); + }; + + const onSwMessage = (e: MessageEvent) => { + const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null; + if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') { + void toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data }); + } + }; + + const interval = setInterval(() => { void appLifecycleHooks.onInterval.emit({ now: new Date().toISOString() }); }, PLUGIN_INTERVAL_MS); + + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onBlur); + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + window.addEventListener('beforeunload', onBeforeUnload); + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onRejection); + document.addEventListener('visibilitychange', onVisibility); + document.addEventListener('selectionchange', onSelectionChange); + navigator.serviceWorker?.addEventListener('message', onSwMessage); + return () => { + clearInterval(interval); + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + window.removeEventListener('beforeunload', onBeforeUnload); + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onRejection); + document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('selectionchange', onSelectionChange); + if (selectionTimer) clearTimeout(selectionTimer); + navigator.serviceWorker?.removeEventListener('message', onSwMessage); + }; + }, []); + + // Navigation between pages of the app. + const pathname = usePathname(); + const prevPathnameRef = useRef(null); + useEffect(() => { + if (!pathname) return; + const from = prevPathnameRef.current; + if (from === pathname) return; + if (from !== null) { + void routerHooks.onRouteLeave.emit({ path: from }); + void routerHooks.onNavigate.emit({ path: pathname, from }); + } + void routerHooks.onRouteEnter.emit({ path: pathname }); + prevPathnameRef.current = pathname; + }, [pathname]); + + // onAppReady: once per page load, when the user is signed in and the + // plugins have been started, so every plugin enabled at start hears it. + const pluginsReady = usePluginStore((s) => s.initialized); + const signedIn = useAuthStore((s) => s.isAuthenticated); + const readyFired = useRef(false); + useEffect(() => { + if (readyFired.current || !pluginsReady || !signedIn) return; + readyFired.current = true; + void appLifecycleHooks.onAppReady.emit({ accountId: useAccountStore.getState().activeAccountId }); + }, [pluginsReady, signedIn]); + + return null; +} diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index a1e1a928a..5b5d56cac 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'auth', 'security', 'theme', 'notifications', 'app', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'auth', 'security', 'theme', 'notifications', 'ui', 'keyboard', ]); describe('hook catalogue', () => { From 02a7dfab163b0e5a5dd4e8a04e683526bd10d73f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:24:00 +0200 Subject: [PATCH 034/147] feat: tell plugins when the colour scheme or theme changes --- lib/__tests__/plugin-hook-catalog.test.ts | 4 +-- lib/__tests__/theme-store-hooks.test.ts | 30 +++++++++++++++++++++++ stores/theme-store.ts | 12 ++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/theme-store-hooks.test.ts diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index 5b5d56cac..f27f7c0b3 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -37,7 +37,7 @@ const sources = ['app', 'components', 'hooks', 'lib', 'stores'].flatMap((d) => s /** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */ function isFired(hookName: string, groupName: string): boolean { - const re = new RegExp(`\\b${groupName}\\.${hookName}\\.(emit|emitSync|emitTo|intercept|interceptOutcome|transform|takeOver)\\b`); + const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|transform|takeOver)\\b`); return re.test(sources); } @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'auth', 'security', 'theme', 'notifications', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'auth', 'security', 'notifications', 'ui', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/lib/__tests__/theme-store-hooks.test.ts b/lib/__tests__/theme-store-hooks.test.ts new file mode 100644 index 000000000..803bf84b8 --- /dev/null +++ b/lib/__tests__/theme-store-hooks.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, themeHooks } from '@/lib/plugin-hooks'; +import { useThemeStore } from '@/stores/theme-store'; + +beforeEach(() => { + clearAllHooks(); + useThemeStore.setState({ theme: 'light', resolvedTheme: 'light', activeThemeId: null }); +}); + +describe('theme hooks', () => { + it('tells plugins when the colour scheme changes', () => { + const fn = vi.fn(); + themeHooks.onThemeChange.register('t', fn); + + useThemeStore.getState().setTheme('dark'); + + expect(fn).toHaveBeenCalledWith({ theme: 'dark', resolved: 'dark' }); + }); + + it('tells plugins when another theme is activated, and not when nothing changed', () => { + const fn = vi.fn(); + themeHooks.onCustomThemeChange.register('t', fn); + + useThemeStore.setState({ activeThemeId: 'ocean' }); + useThemeStore.setState({ activeThemeId: 'ocean' }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ themeId: 'ocean' }); + }); +}); diff --git a/stores/theme-store.ts b/stores/theme-store.ts index d06df2822..5e31ac3fa 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -644,4 +644,14 @@ async function downloadThemeAsset(theme: ServerThemeInfo, kind: 'css' | 'skin'): console.warn(`[theme-store] Failed to download ${kind} for theme "${theme.id}"`); return null; } -} \ No newline at end of file +} +// Plugins hear about every change of scheme or theme, whichever path made it +// (settings, the system switching to dark, a forced admin theme). +useThemeStore.subscribe((state, prev) => { + if (state.theme !== prev.theme || state.resolvedTheme !== prev.resolvedTheme) { + void themeHooks.onThemeChange.emit({ theme: state.theme, resolved: state.resolvedTheme }); + } + if (state.activeThemeId !== prev.activeThemeId) { + void themeHooks.onCustomThemeChange.emit({ themeId: state.activeThemeId }); + } +}); From 96f8aa6a6323484eb5a3658c4047532aa5c690d7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:24:19 +0200 Subject: [PATCH 035/147] feat: tell plugins when contacts are created, changed or deleted, and let them stop it --- components/contacts/contacts-app.tsx | 54 ++-- components/email/email-viewer.tsx | 6 +- stores/__tests__/contact-store-hooks.test.ts | 251 +++++++++++++++++++ stores/__tests__/contact-store.test.ts | 54 ++-- stores/contact-store.ts | 82 ++++-- 5 files changed, 364 insertions(+), 83 deletions(-) create mode 100644 stores/__tests__/contact-store-hooks.test.ts diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index b72efc7c1..07899ff28 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -347,7 +347,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (supportsSync && client) { await updateContact(client, contactId, { keywords: updatedKeywords }); } else { - updateLocalContact(contactId, { keywords: updatedKeywords }); + await updateLocalContact(contactId, { keywords: updatedKeywords }); } } const msg = contactIds.length === 1 @@ -427,11 +427,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (!confirmed) return; try { - if (supportsSync && client) { - await deleteContact(client, contactId); - } else { - deleteLocalContact(contactId); - } + const deleted = supportsSync && client + ? await deleteContact(client, contactId) + : await deleteLocalContact(contactId); + // A plugin stopped it and has told the user why. + if (!deleted) return; toast.success(t("toast.deleted")); if (selectedContactId === contactId) setView("list"); } catch (error) { @@ -468,33 +468,27 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source; void _id; void _uid; void _created; void _updated; const data: Partial = JSON.parse(JSON.stringify(rest)); - if (supportsSync && client) { - await createContact(client, data); - toast.success(t("toast.created")); - } else { - const localContact: ContactCard = { + const created = supportsSync && client + ? await createContact(client, data) + : await addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: data.addressBookIds || {}, ...data, - }; - addLocalContact(localContact); - toast.success(t("toast.created")); - } + }); + if (created) toast.success(t("toast.created")); }, [supportsSync, client, createContact, addLocalContact, t]); const handleSaveNew = useCallback(async (data: Partial) => { - if (supportsSync && client) { - await createContact(client, data); - toast.success(t("toast.created")); - } else { - const localContact: ContactCard = { + const created = supportsSync && client + ? await createContact(client, data) + : await addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...data, - }; - addLocalContact(localContact); - toast.success(t("toast.created")); - } + }); + // A plugin stopped it: keep the form open with what was typed. + if (!created) return; + toast.success(t("toast.created")); setDefaultBookIdForCreate(undefined); setCreatePrefill(undefined); if (returnToEmail) { @@ -508,13 +502,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const handleSaveEdit = useCallback(async (data: Partial) => { if (!selectedContact) return; - if (supportsSync && client) { - await updateContact(client, selectedContact.id, data); - toast.success(t("toast.updated")); - } else { - updateLocalContact(selectedContact.id, data); - toast.success(t("toast.updated")); - } + const updated = supportsSync && client + ? await updateContact(client, selectedContact.id, data) + : await updateLocalContact(selectedContact.id, data); + if (!updated) return; + toast.success(t("toast.updated")); setView("detail"); }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8fc8e9118..4ba1e06cf 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -6178,10 +6178,10 @@ export function EmailViewer({ }} : {}), }; if (client && supportsSync) { - createContact(client, contactData).then(() => toast.success('Contact added')); + createContact(client, contactData).then((created) => { if (created) toast.success('Contact added'); }); } else { - addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard); - toast.success('Contact added'); + void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard) + .then((created) => { if (created) toast.success('Contact added'); }); } }} /> diff --git a/stores/__tests__/contact-store-hooks.test.ts b/stores/__tests__/contact-store-hooks.test.ts new file mode 100644 index 000000000..88cfc6ae7 --- /dev/null +++ b/stores/__tests__/contact-store-hooks.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useContactStore } from '../contact-store'; +import { contactHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const card = (overrides: Partial = {}): ContactCard => ({ + id: 'c1', + addressBookIds: { 'ab-1': true }, + name: { full: 'Jane Doe', components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }], isOrdered: true }, + emails: { e0: { address: 'jane@example.com' } }, + ...overrides, +}); + +const makeClient = (overrides: Record = {}) => ({ + createContact: vi.fn(async (c: Partial) => ({ ...c, id: 'srv-1' } as ContactCard)), + updateContact: vi.fn(async () => undefined), + deleteContact: vi.fn(async () => undefined), + ...overrides, +}) as unknown as IJMAPClient & Record>; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('contact-store plugin hooks: contact CRUD', () => { + beforeEach(() => { + clearAllHooks(); + useContactStore.setState({ + contacts: [], addressBooks: [], selectedContactId: null, error: null, + supportsSync: true, selectedContactIds: new Set(), lastSelectedContactId: null, + }); + }); + + describe('createContact', () => { + it('asks plugins first and tells them about the created contact', async () => { + const client = makeClient(); + const before = vi.fn(() => { + expect(client.createContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactCreate.register('test', before); + contactHooks.onAfterContactCreate.register('test', after); + + const { id: _id, ...draft } = card(); + void _id; + expect(await useContactStore.getState().createContact(client, draft)).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + expect(before).toHaveBeenCalledWith(expect.objectContaining({ id: '', fullName: 'Jane Doe', emails: ['jane@example.com'] })); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'srv-1', fullName: 'Jane Doe', addressBookId: 'ab-1' }); + }); + + it('creates nothing when a plugin says no', async () => { + const client = makeClient(); + const after = vi.fn(); + contactHooks.onBeforeContactCreate.register('test', () => false); + contactHooks.onAfterContactCreate.register('test', after); + + expect(await useContactStore.getState().createContact(client, card())).toBe(false); + await flush(); + + expect(client.createContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toEqual([]); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a contact the server refused', async () => { + const client = makeClient({ createContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const after = vi.fn(); + contactHooks.onAfterContactCreate.register('test', after); + + await expect(useContactStore.getState().createContact(client, card())).rejects.toThrow('nope'); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('updateContact', () => { + it('passes the id and patch first, then the changed contact', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient(); + const before = vi.fn(() => { + expect(client.updateContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactUpdate.register('test', before); + contactHooks.onAfterContactUpdate.register('test', after); + + const patch = { emails: { e0: { address: 'new@example.com' } } }; + expect(await useContactStore.getState().updateContact(client, 'c1', patch)).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledWith({ contactId: 'c1', patch }); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'c1', emails: ['new@example.com'] }); + }); + + it('changes nothing when a plugin says no', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient(); + const after = vi.fn(); + contactHooks.onBeforeContactUpdate.register('test', () => false); + contactHooks.onAfterContactUpdate.register('test', after); + + expect(await useContactStore.getState().updateContact(client, 'c1', { kind: 'org' })).toBe(false); + await flush(); + + expect(client.updateContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts[0].kind).toBeUndefined(); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report an update the server refused', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient({ updateContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const after = vi.fn(); + contactHooks.onAfterContactUpdate.register('test', after); + + await expect(useContactStore.getState().updateContact(client, 'c1', { kind: 'org' })).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('deleteContact', () => { + it('asks plugins first and reports the deleted id', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient(); + const before = vi.fn(() => { + expect(client.deleteContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactDelete.register('test', before); + contactHooks.onAfterContactDelete.register('test', after); + + expect(await useContactStore.getState().deleteContact(client, 'c1')).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledWith({ contactIds: ['c1'] }); + expect(after).toHaveBeenCalledWith({ contactIds: ['c1'] }); + expect(useContactStore.getState().contacts).toEqual([]); + }); + + it('keeps the contact when a plugin says no', async () => { + useContactStore.setState({ contacts: [card()], selectedContactId: 'c1' }); + const client = makeClient(); + const after = vi.fn(); + contactHooks.onBeforeContactDelete.register('test', () => false); + contactHooks.onAfterContactDelete.register('test', after); + + expect(await useContactStore.getState().deleteContact(client, 'c1')).toBe(false); + await flush(); + + expect(client.deleteContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toHaveLength(1); + expect(useContactStore.getState().selectedContactId).toBe('c1'); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a delete the server refused', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient({ deleteContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const after = vi.fn(); + contactHooks.onAfterContactDelete.register('test', after); + + await expect(useContactStore.getState().deleteContact(client, 'c1')).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('local contacts (no server sync)', () => { + beforeEach(() => useContactStore.setState({ supportsSync: false })); + + it('fires the same hooks for create, update and delete', async () => { + const created = vi.fn(); + const updated = vi.fn(); + const deleted = vi.fn(); + contactHooks.onAfterContactCreate.register('test', created); + contactHooks.onAfterContactUpdate.register('test', updated); + contactHooks.onAfterContactDelete.register('test', deleted); + + await useContactStore.getState().addLocalContact(card({ id: 'local-1' })); + await useContactStore.getState().updateLocalContact('local-1', { kind: 'org' }); + await useContactStore.getState().deleteLocalContact('local-1'); + await flush(); + + expect(created.mock.calls[0][0]).toMatchObject({ id: 'local-1', fullName: 'Jane Doe' }); + expect(updated.mock.calls[0][0]).toMatchObject({ id: 'local-1', kind: 'org' }); + expect(deleted).toHaveBeenCalledWith({ contactIds: ['local-1'] }); + }); + + it('leaves the list alone when plugins say no', async () => { + useContactStore.setState({ contacts: [card({ id: 'local-1' })] }); + contactHooks.onBeforeContactCreate.register('test', () => false); + contactHooks.onBeforeContactUpdate.register('test', () => false); + contactHooks.onBeforeContactDelete.register('test', () => false); + + expect(await useContactStore.getState().addLocalContact(card({ id: 'local-2' }))).toBe(false); + expect(await useContactStore.getState().updateLocalContact('local-1', { kind: 'org' })).toBe(false); + expect(await useContactStore.getState().deleteLocalContact('local-1')).toBe(false); + + const { contacts } = useContactStore.getState(); + expect(contacts.map(c => c.id)).toEqual(['local-1']); + expect(contacts[0].kind).toBeUndefined(); + }); + }); + + describe('bulkDeleteContacts', () => { + it('asks once with every id and reports only the ones that went', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' }), card({ id: 'b' }), card({ id: 'c' })] }); + const client = makeClient({ + deleteContact: vi.fn(async (id: string) => { if (id === 'b') throw new Error('locked'); }), + }); + const before = vi.fn(() => { + expect(client.deleteContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactDelete.register('test', before); + contactHooks.onAfterContactDelete.register('test', after); + + await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b', 'c']); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + expect(before).toHaveBeenCalledWith({ contactIds: ['a', 'b', 'c'] }); + expect(after).toHaveBeenCalledOnce(); + expect(after).toHaveBeenCalledWith({ contactIds: ['a', 'c'] }); + }); + + it('deletes nothing when a plugin says no', async () => { + useContactStore.setState({ + contacts: [card({ id: 'a' }), card({ id: 'b' })], + selectedContactIds: new Set(['a', 'b']), + }); + const client = makeClient(); + contactHooks.onBeforeContactDelete.register('test', () => false); + + await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b']); + + expect(client.deleteContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toHaveLength(2); + expect(useContactStore.getState().selectedContactIds.size).toBe(2); + }); + }); +}); diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index 89da2d6d4..ff9b46e3a 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -42,64 +42,64 @@ describe('contact-store', () => { }); describe('addLocalContact', () => { - it('should append contact to array', () => { - useContactStore.getState().addLocalContact(makeContact()); + it('should append contact to array', async () => { + await useContactStore.getState().addLocalContact(makeContact()); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('contact-1'); }); - it('should preserve existing contacts', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); + it('should preserve existing contacts', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); expect(useContactStore.getState().contacts).toHaveLength(2); }); }); describe('updateLocalContact', () => { - it('should update matching contact', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().updateLocalContact('c1', { + it('should update matching contact', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'updated@example.com' } }, }); expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com'); }); - it('should not modify other contacts', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } })); - useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } }); + it('should not modify other contacts', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } })); + await useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } }); expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com'); }); - it('should no-op for non-existent id', () => { - useContactStore.getState().addLocalContact(makeContact()); - useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' }); + it('should no-op for non-existent id', async () => { + await useContactStore.getState().addLocalContact(makeContact()); + await useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' }); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].kind).toBeUndefined(); }); }); describe('deleteLocalContact', () => { - it('should remove contact by id', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); - useContactStore.getState().deleteLocalContact('c1'); + it('should remove contact by id', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); + await useContactStore.getState().deleteLocalContact('c1'); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('c2'); }); - it('should clear selectedContactId when deleting selected', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + it('should clear selectedContactId when deleting selected', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().setSelectedContact('c1'); - useContactStore.getState().deleteLocalContact('c1'); + await useContactStore.getState().deleteLocalContact('c1'); expect(useContactStore.getState().selectedContactId).toBeNull(); }); - it('should preserve selectedContactId when deleting other', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); + it('should preserve selectedContactId when deleting other', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); useContactStore.getState().setSelectedContact('c1'); - useContactStore.getState().deleteLocalContact('c2'); + await useContactStore.getState().deleteLocalContact('c2'); expect(useContactStore.getState().selectedContactId).toBe('c1'); }); }); @@ -574,7 +574,7 @@ describe('contact-store', () => { }); it('should preserve selectedContactId when deleting other group', async () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().setSelectedContact('c1'); await useContactStore.getState().createGroup(null, 'Team', []); const groupId = useContactStore.getState().contacts[1].id; diff --git a/stores/contact-store.ts b/stores/contact-store.ts index cef716d91..985752935 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -6,6 +6,13 @@ import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; import { getClientByLocalAccountId } from './client-registry'; import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox'; +import { contactHooks } from '@/lib/plugin-hooks'; +import { contactToView } from '@/lib/plugin-projection'; + +/** A contact that is about to be created, projected for plugins (no id yet). */ +function draftContactView(contact: Partial) { + return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard); +} /** One compose-autocomplete suggestion: a person, or a contact group. */ type RecipientSuggestion = { name: string; email: string; group?: { id: string; memberCount: number } }; @@ -343,13 +350,15 @@ interface ContactStore { fetchAddressBooks: (client: IJMAPClient) => Promise; fetchAllAccountsContacts: (accounts: ContactAccountClient[]) => Promise; fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[]) => Promise; - createContact: (client: IJMAPClient, contact: Partial) => Promise; - updateContact: (client: IJMAPClient, id: string, updates: Partial) => Promise; - deleteContact: (client: IJMAPClient, id: string) => Promise; + // The contact actions resolve to false when a plugin stopped them + // (onBeforeContactCreate / Update / Delete); nothing changed then. + createContact: (client: IJMAPClient, contact: Partial) => Promise; + updateContact: (client: IJMAPClient, id: string, updates: Partial) => Promise; + deleteContact: (client: IJMAPClient, id: string) => Promise; - addLocalContact: (contact: ContactCard) => void; - updateLocalContact: (id: string, updates: Partial) => void; - deleteLocalContact: (id: string) => void; + addLocalContact: (contact: ContactCard) => Promise; + updateLocalContact: (id: string, updates: Partial) => Promise; + deleteLocalContact: (id: string) => Promise; setSelectedContact: (id: string | null) => void; setSearchQuery: (query: string) => void; @@ -537,6 +546,7 @@ export const useContactStore = create()( }, createContact: async (client, contact) => { + if (!(await contactHooks.onBeforeContactCreate.intercept(draftContactView(contact)))) return false; set({ isLoading: true, error: null }); try { // Determine target account from the selected address book. Also @@ -572,6 +582,8 @@ export const useContactStore = create()( contacts: [...state.contacts, created], isLoading: false, })); + void contactHooks.onAfterContactCreate.emit(contactToView(created)); + return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to create contact'; set({ error: msg, isLoading: false }); @@ -580,6 +592,7 @@ export const useContactStore = create()( }, updateContact: async (client, id, updates) => { + if (!(await contactHooks.onBeforeContactUpdate.intercept({ contactId: id, patch: updates }))) return false; set({ error: null }); try { const contact = get().contacts.find(c => c.id === id); @@ -602,6 +615,9 @@ export const useContactStore = create()( c.id === id ? { ...c, ...updates } : c ), })); + const updated = get().contacts.find(c => c.id === id); + if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated)); + return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to update contact'; set({ error: msg }); @@ -610,6 +626,7 @@ export const useContactStore = create()( }, deleteContact: async (client, id) => { + if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [id] }))) return false; set({ error: null }); try { const contact = get().contacts.find(c => c.id === id); @@ -625,6 +642,8 @@ export const useContactStore = create()( selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, }; }); + void contactHooks.onAfterContactDelete.emit({ contactIds: [id] }); + return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete contact'; set({ error: msg }); @@ -632,24 +651,38 @@ export const useContactStore = create()( } }, - addLocalContact: (contact) => set((state) => ({ - contacts: [...state.contacts, contact], - })), + addLocalContact: async (contact) => { + if (!(await contactHooks.onBeforeContactCreate.intercept(draftContactView(contact)))) return false; + set((state) => ({ contacts: [...state.contacts, contact] })); + void contactHooks.onAfterContactCreate.emit(contactToView(contact)); + return true; + }, - updateLocalContact: (id, updates) => set((state) => ({ - contacts: state.contacts.map(c => - c.id === id ? { ...c, ...updates } : c - ), - })), + updateLocalContact: async (id, updates) => { + if (!(await contactHooks.onBeforeContactUpdate.intercept({ contactId: id, patch: updates }))) return false; + set((state) => ({ + contacts: state.contacts.map(c => + c.id === id ? { ...c, ...updates } : c + ), + })); + const updated = get().contacts.find(c => c.id === id); + if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated)); + return true; + }, - deleteLocalContact: (id) => set((state) => { - const removedIds = new Set([id]); - const cleaned = cleanGroupMembers(state.contacts, removedIds); - return { - contacts: cleaned.filter(c => c.id !== id), - selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, - }; - }), + deleteLocalContact: async (id) => { + if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [id] }))) return false; + set((state) => { + const removedIds = new Set([id]); + const cleaned = cleanGroupMembers(state.contacts, removedIds); + return { + contacts: cleaned.filter(c => c.id !== id), + selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, + }; + }); + void contactHooks.onAfterContactDelete.emit({ contactIds: [id] }); + return true; + }, setSelectedContact: (id) => set({ selectedContactId: id }), setSearchQuery: (query) => set({ searchQuery: query }), @@ -939,6 +972,8 @@ export const useContactStore = create()( clearSelection: () => set({ selectedContactIds: new Set(), lastSelectedContactId: null }), bulkDeleteContacts: async (client, ids) => { + if (ids.length === 0) return; + if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [...ids] }))) return; set({ error: null }); const { supportsSync, contacts } = get(); const deletedIds = new Set(ids); @@ -968,6 +1003,9 @@ export const useContactStore = create()( selectedContactIds: new Set(), }; }); + if (deletedIds.size > 0) { + void contactHooks.onAfterContactDelete.emit({ contactIds: ids.filter(id => deletedIds.has(id)) }); + } }, bulkAddToGroup: async (client, groupId, contactIds) => { From 77088a6008f8b424454603bb1ea62559ebe02feb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:25:53 +0200 Subject: [PATCH 036/147] feat: tell plugins when toasts are shown and dismissed --- lib/__tests__/plugin-hook-catalog.test.ts | 2 +- lib/plugin-hook-catalog.ts | 3 +- lib/plugin-hooks.ts | 1 - stores/__tests__/toast-store-hooks.test.ts | 32 ++++++++++++++++++++++ stores/toast-store.ts | 8 +++++- 5 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 stores/__tests__/toast-store-hooks.test.ts diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index f27f7c0b3..b3ce7ee0a 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'auth', 'security', 'notifications', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'auth', 'security', 'ui', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 689d15778..d8d156a91 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -268,9 +268,8 @@ export const HOOK_CATALOG = { onLocaleChange: H('theme', 'observer', 'ui:observe', '{ locale }', 'after the app language changed'), // ─── Notifications ───────────────────────────────────────── - onToastShow: H('notifications', 'observer', 'ui:observe', '{ type, message }', 'when the app shows a toast'), + onToastShow: H('notifications', 'observer', 'ui:observe', '{ id, type, title, message }', 'when the app shows a toast'), onToastDismiss: H('notifications', 'observer', 'ui:observe', '{ id }', 'when a toast is dismissed'), - onBrowserNotification: H('notifications', 'transform', 'email:read', '{ title, body, tag } | false, { kind: "mail" | "calendar" | "plugin" }', 'before a system notification is shown; return false to suppress it'), onNotificationClick: H('notifications', 'observer', 'email:read', '{ tag, data }', 'when a system notification is clicked'), // ─── App lifecycle ───────────────────────────────────────── diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index af19b455f..6667c1e91 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -685,7 +685,6 @@ export const themeHooks = { export const toastHooks = { onToastShow: new HookBus(), onToastDismiss: new HookBus(), - onBrowserNotification: new HookBus(), // Observer fired when the user clicks an OS-level browser notification // dispatched by the host. Handler receives { tag: string, data?: unknown } // matching the original notification options. diff --git a/stores/__tests__/toast-store-hooks.test.ts b/stores/__tests__/toast-store-hooks.test.ts new file mode 100644 index 000000000..777d45d05 --- /dev/null +++ b/stores/__tests__/toast-store-hooks.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, toastHooks } from '@/lib/plugin-hooks'; +import { useToastStore } from '../toast-store'; + +beforeEach(() => { + clearAllHooks(); + useToastStore.setState({ toasts: [] }); +}); + +describe('toast hooks', () => { + it('tells plugins about a toast when it is shown and when it goes away', () => { + const shown = vi.fn(); + const dismissed = vi.fn(); + toastHooks.onToastShow.register('t', shown); + toastHooks.onToastDismiss.register('t', dismissed); + + const id = useToastStore.getState().addToast({ type: 'success', title: 'Moved', message: '3 messages' }); + useToastStore.getState().removeToast(id); + + expect(shown).toHaveBeenCalledWith({ id, type: 'success', title: 'Moved', message: '3 messages' }); + expect(dismissed).toHaveBeenCalledWith({ id }); + }); + + it('does not report a dismissal for a toast that is not there', () => { + const dismissed = vi.fn(); + toastHooks.onToastDismiss.register('t', dismissed); + + useToastStore.getState().removeToast('gone'); + + expect(dismissed).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/toast-store.ts b/stores/toast-store.ts index 65b18f287..8c114c3c4 100644 --- a/stores/toast-store.ts +++ b/stores/toast-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { Toast, ToastAction } from "@/components/ui/toast"; import { generateUUID } from "@/lib/utils"; +import { toastHooks } from "@/lib/plugin-hooks"; interface ToastStore { toasts: Toast[]; @@ -11,7 +12,7 @@ interface ToastStore { clearToasts: () => void; } -export const useToastStore = create((set) => ({ +export const useToastStore = create((set, get) => ({ toasts: [], addToast: (toast) => { @@ -29,6 +30,9 @@ export const useToastStore = create((set) => ({ set((state) => ({ toasts: [...state.toasts, newToast], })); + void toastHooks.onToastShow.emit({ + id, type: toast.type, title: toast.title, message: toast.message ?? "", + }); return id; }, @@ -39,9 +43,11 @@ export const useToastStore = create((set) => ({ }, removeToast: (id) => { + if (!get().toasts.some((t) => t.id === id)) return; set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id), })); + void toastHooks.onToastDismiss.emit({ id }); }, clearToasts: () => { From f530f5849d7c0adec56fd4955a7a6777ef874224 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:26:03 +0200 Subject: [PATCH 037/147] feat: tell plugins when preferences change, are imported, exported, reset or synced, and let them stop a change --- .../settings-store-plugin-hooks.test.ts | 193 ++++++++++++++++++ stores/auth-store.ts | 4 +- stores/contact-store.ts | 4 + stores/settings-store.ts | 169 +++++++++++---- 4 files changed, 330 insertions(+), 40 deletions(-) create mode 100644 stores/__tests__/settings-store-plugin-hooks.test.ts diff --git a/stores/__tests__/settings-store-plugin-hooks.test.ts b/stores/__tests__/settings-store-plugin-hooks.test.ts new file mode 100644 index 000000000..f0a64283c --- /dev/null +++ b/stores/__tests__/settings-store-plugin-hooks.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetch = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', () => ({ apiFetch })); + +import { useSettingsStore, DEFAULT_KEYWORDS } from '../settings-store'; +import { useContactStore } from '../contact-store'; +import { clearAllHooks, settingsHooks } from '@/lib/plugin-hooks'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('settings plugin hooks', () => { + beforeEach(() => { + apiFetch.mockReset(); + useSettingsStore.getState().disableSync(); + useSettingsStore.setState({ + sendDelaySeconds: 0, + tourCompleted: false, + trustedSenders: [], + folderIcons: {}, + emailKeywords: [...DEFAULT_KEYWORDS], + settingsSyncDisabled: false, + }); + }); + + afterEach(() => clearAllHooks()); + + it('asks onBeforeSettingChange first and reports the change afterwards', async () => { + const order: string[] = []; + const before = vi.fn(() => { order.push(`before:${useSettingsStore.getState().sendDelaySeconds}`); return true; }); + const after = vi.fn(() => { order.push('after'); }); + settingsHooks.onBeforeSettingChange.register('test', before); + settingsHooks.onSettingChange.register('test', after); + + useSettingsStore.getState().updateSetting('sendDelaySeconds', 30); + await flush(); + + expect(before).toHaveBeenCalledWith({ key: 'sendDelaySeconds', value: 30, previous: 0 }); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith({ key: 'sendDelaySeconds', value: 30, previous: 0 }); + expect(order).toEqual(['before:0', 'after']); + expect(useSettingsStore.getState().sendDelaySeconds).toBe(30); + }); + + it('keeps the old value when a plugin says no', async () => { + const after = vi.fn(); + settingsHooks.onBeforeSettingChange.register('test', () => false); + settingsHooks.onSettingChange.register('test', after); + + useSettingsStore.getState().updateSetting('sendDelaySeconds', 30); + await flush(); + + expect(useSettingsStore.getState().sendDelaySeconds).toBe(0); + expect(after).not.toHaveBeenCalled(); + }); + + it('applies synchronously when no plugin intercepts', () => { + const after = vi.fn(); + settingsHooks.onSettingChange.register('test', after); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 10); + expect(useSettingsStore.getState().sendDelaySeconds).toBe(10); + expect(after).toHaveBeenCalledWith({ key: 'sendDelaySeconds', value: 10, previous: 0 }); + }); + + it('leaves bookkeeping and internal writes out of the setting hooks', async () => { + const before = vi.fn(() => false); + const after = vi.fn(); + settingsHooks.onBeforeSettingChange.register('test', before); + settingsHooks.onSettingChange.register('test', after); + + useSettingsStore.getState().updateSetting('tourCompleted', true); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 60, { internal: true }); + await flush(); + + expect(useSettingsStore.getState().tourCompleted).toBe(true); + expect(useSettingsStore.getState().sendDelaySeconds).toBe(60); + expect(before).not.toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + }); + + it('routes folder icon changes through the setting hooks', async () => { + settingsHooks.onBeforeSettingChange.register('test', () => false); + useSettingsStore.getState().setFolderIcon('mb1', 'Star'); + await flush(); + expect(useSettingsStore.getState().folderIcons).toEqual({}); + }); + + it('reports export but not the sync snapshot', () => { + const exported = vi.fn(); + settingsHooks.onSettingsExport.register('test', exported); + + useSettingsStore.getState().exportSettings({ forSync: true }); + expect(exported).not.toHaveBeenCalled(); + + useSettingsStore.getState().exportSettings(); + expect(exported).toHaveBeenCalledTimes(1); + const { keys } = exported.mock.calls[0][0] as { keys: string[] }; + expect(keys).toContain('sendDelaySeconds'); + expect(keys).toContain('theme'); + }); + + it('reports a file import with the keys taken over, and not a failed one', () => { + const imported = vi.fn(); + settingsHooks.onSettingsImport.register('test', imported); + + expect(useSettingsStore.getState().importSettings('not json')).toBe(false); + expect(imported).not.toHaveBeenCalled(); + + useSettingsStore.getState().importSettings(JSON.stringify({ sendDelaySeconds: 30, unknownKey: 1, proInterface: true })); + expect(imported).toHaveBeenCalledTimes(1); + expect(imported).toHaveBeenCalledWith({ keys: ['sendDelaySeconds'] }); + }); + + it('reports a reset', () => { + const reset = vi.fn(); + settingsHooks.onSettingsReset.register('test', reset); + useSettingsStore.getState().resetToDefaults(); + expect(reset).toHaveBeenCalledWith({}); + }); + + it('reports a pull from the server as a sync, not an import', async () => { + const synced = vi.fn(); + const imported = vi.fn(); + settingsHooks.onSettingsSync.register('test', synced); + settingsHooks.onSettingsImport.register('test', imported); + apiFetch.mockResolvedValue(new Response(JSON.stringify({ settings: { sendDelaySeconds: 10 } }), { status: 200 })); + + await useSettingsStore.getState().loadFromServer('a@example.com', 'https://mail.example.com'); + + expect(synced).toHaveBeenCalledWith({ direction: 'pulled' }); + expect(imported).not.toHaveBeenCalled(); + }); + + it('reports a push only when the server accepted it', async () => { + const synced = vi.fn(); + settingsHooks.onSettingsSync.register('test', synced); + const settings = useSettingsStore.getState(); + settings.enableSync('a@example.com', 'https://mail.example.com'); + + apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'nope' }), { status: 400 })); + settings.updateSetting('sendDelaySeconds', 10); + await useSettingsStore.getState().flushSync(); + expect(synced).not.toHaveBeenCalled(); + + apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 30); + await useSettingsStore.getState().flushSync(); + expect(synced).toHaveBeenCalledWith({ direction: 'pushed' }); + }); + + it('reports tag definition changes as keyword views', () => { + const changed = vi.fn(); + settingsHooks.onKeywordChange.register('test', changed); + useSettingsStore.getState().addKeyword({ id: 'work', label: 'Work', color: 'blue' }); + expect(changed).toHaveBeenCalledTimes(1); + const { keywords } = changed.mock.calls[0][0] as { keywords: unknown[] }; + expect(keywords).toContainEqual({ id: 'work', name: 'Work', color: 'blue' }); + + // Writing an identical list is no change. + useSettingsStore.getState().reorderKeywords([...useSettingsStore.getState().emailKeywords]); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('reports trusted senders added and removed, once each', () => { + const changed = vi.fn(); + settingsHooks.onTrustedSenderChange.register('test', changed); + const s = useSettingsStore.getState(); + s.addTrustedSender('Alice '); + s.addTrustedSender('alice@example.com'); + s.removeTrustedSender('alice@example.com'); + s.removeTrustedSender('alice@example.com'); + expect(changed.mock.calls).toEqual([ + [{ email: 'alice@example.com', trusted: true }], + [{ email: 'alice@example.com', trusted: false }], + ]); + }); + + it('reports trusted senders kept in the address book after the server stored them', async () => { + const changed = vi.fn(); + settingsHooks.onTrustedSenderChange.register('test', changed); + useContactStore.setState({ trustedSendersBookId: 'book1', trustedSenderEmails: [] }); + const client = { + createContact: vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValue({ id: 'c1' }), + } as unknown as IJMAPClient; + + await expect(useContactStore.getState().addToTrustedSendersBook(client, 'bob@example.com')).rejects.toThrow(); + expect(changed).not.toHaveBeenCalled(); + + await useContactStore.getState().addToTrustedSendersBook(client, 'bob@example.com'); + expect(changed).toHaveBeenCalledWith({ email: 'bob@example.com', trusted: true }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index b2c5a8b79..700d9a73d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -809,7 +809,7 @@ export function applyPreferredIdentity(accountId?: string | null): void { if (legacy) { preferred = legacy; const current = useSettingsStore.getState().preferredIdentityIds; - useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, [targetId]: legacy }); + useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, [targetId]: legacy }, { internal: true }); } } if (!preferred) return; @@ -931,7 +931,7 @@ function initializeFeatureStores(client: IJMAPClient): void { // user has already made an explicit choice (`null` = not yet decided). const settings = useSettingsStore.getState(); if (settings.trustedSendersAddressBook === null) { - settings.updateSetting('trustedSendersAddressBook', true); + settings.updateSetting('trustedSendersAddressBook', true, { internal: true }); } } else { useContactStore.getState().setSupportsSync(false); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index cef716d91..d81d4e6c8 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -4,6 +4,7 @@ import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; +import { settingsHooks } from '@/lib/plugin-hooks'; import { getClientByLocalAccountId } from './client-registry'; import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox'; @@ -1276,6 +1277,7 @@ export const useContactStore = create()( }); set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, emailAddress] })); debug.log('contacts', 'Trusted sender added successfully'); + void settingsHooks.onTrustedSenderChange.emit({ email: emailAddress, trusted: true }); }, removeFromTrustedSendersBook: async (client, email) => { @@ -1295,7 +1297,9 @@ export const useContactStore = create()( await client.deleteContact(match.id); debug.log('contacts', 'Trusted sender removed'); } + const wasTrusted = get().trustedSenderEmails.includes(normalizedEmail); set((state) => ({ trustedSenderEmails: state.trustedSenderEmails.filter(e => e !== normalizedEmail) })); + if (wasTrusted) void settingsHooks.onTrustedSenderChange.emit({ email: normalizedEmail, trusted: false }); }, isTrustedAddressBookSender: (email) => { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index d7f02625f..99ce6ba2b 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -5,6 +5,7 @@ import { useLocaleStore } from './locale-store'; import type { EmailTemplate } from '@/lib/template-types'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { apiFetch } from '@/lib/browser-navigation'; +import { settingsHooks } from '@/lib/plugin-hooks'; import { generateAccountId } from '@/lib/account-utils'; import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order'; import { @@ -153,9 +154,50 @@ async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise syncError('Settings sync failed:', body.error || `status ${res.status}`); } else { syncLog('Settings synced to server successfully'); + void settingsHooks.onSettingsSync.emit({ direction: 'pushed' }); } } +/** + * Keys the app writes for its own bookkeeping rather than because the user + * changed a preference. Plugins are neither asked nor told about these: a + * plugin vetoing "the welcome banner was dismissed" would leave the banner + * stuck, and reporting it as a preference change is noise. + * + * How the line is drawn: a key belongs here when only the app writes it (a + * dismissed banner, a finished tour) and Settings shows no control for it. + * Everything else set through updateSetting is a user preference. When the + * app itself writes a user-facing key (a one-time migration, a default + * resolved on first connect), the caller passes `{ internal: true }`. + * Imports, resets and server loads have their own hooks (onSettingsImport, + * onSettingsReset, onSettingsSync) and do not fire per-key ones. + */ +const BOOKKEEPING_SETTING_KEYS = new Set(['onboardingCompleted', 'tourCompleted']); + +/** + * Runs a user's change to one preference through the plugin hooks: asks + * onBeforeSettingChange first (false keeps the old value), applies it, then + * tells onSettingChange. With no interceptor registered the change applies + * synchronously, so inputs bound to the store keep behaving as before. + */ +function changePreference(key: string, value: unknown, previous: unknown, apply: () => void): void { + const commit = () => { + apply(); + void settingsHooks.onSettingChange.emit({ key, value, previous }); + }; + if (settingsHooks.onBeforeSettingChange.size === 0) { + commit(); + return; + } + void settingsHooks.onBeforeSettingChange.intercept({ key, value, previous }).then((allowed) => { + if (allowed) commit(); + }); +} + +function toKeywordViews(keywords: KeywordDefinition[]): { id: string; name: string; color: string }[] { + return keywords.map((k) => ({ id: k.id, name: k.label, color: k.color })); +} + export type FontSize = 'small' | 'medium' | 'large'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; /** @@ -627,12 +669,19 @@ interface SettingsState { settingsSyncDisabled: boolean; // Actions + /** + * Changes one preference. Plugins may stop the change (onBeforeSettingChange) + * and are told about it afterwards (onSettingChange). Pass `internal: true` + * for writes the app makes on its own; those skip the plugin hooks. + */ updateSetting: ( key: K, - value: SettingsState[K] + value: SettingsState[K], + opts?: { internal?: boolean } ) => void; resetToDefaults: () => void; - exportSettings: () => string; + /** `forSync` builds the snapshot pushed to the server; it is not a user export. */ + exportSettings: (opts?: { forSync?: boolean }) => string; importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => boolean; // Folder icons @@ -915,11 +964,8 @@ export const useSettingsStore = create()( applyAnimations(DEFAULT_SETTINGS.animationsEnabled); }; - return { - ...DEFAULT_SETTINGS, - - updateSetting: (key, value) => { - set({ [key]: value }); + const applySetting = (key: K, value: SettingsState[K]) => { + set({ [key]: value } as Partial); // Apply font size to document root if (key === 'fontSize') { @@ -941,17 +987,9 @@ export const useSettingsStore = create()( if (key === 'animationsEnabled') { applyAnimations(value as boolean); } - }, + }; - resetToDefaults: () => { - set(DEFAULT_SETTINGS); - applyFontSize(DEFAULT_SETTINGS.fontSize); - applyDensity(DEFAULT_SETTINGS.density, DEFAULT_SETTINGS.interfaceLayout); - applyInterfaceLayout(DEFAULT_SETTINGS.interfaceLayout, DEFAULT_SETTINGS.density); - applyAnimations(DEFAULT_SETTINGS.animationsEnabled); - }, - - exportSettings: () => { + const buildExport = (): string => { const state = get(); const templateSync = templateSyncBridge?.getSyncedState(); const settings = { @@ -1080,6 +1118,34 @@ export const useSettingsStore = create()( : {}), }; return JSON.stringify(settings, null, 2); + }; + + return { + ...DEFAULT_SETTINGS, + + updateSetting: (key, value, opts) => { + if (opts?.internal || BOOKKEEPING_SETTING_KEYS.has(key)) { + applySetting(key, value); + return; + } + changePreference(key, value, get()[key], () => applySetting(key, value)); + }, + + resetToDefaults: () => { + set(DEFAULT_SETTINGS); + applyFontSize(DEFAULT_SETTINGS.fontSize); + applyDensity(DEFAULT_SETTINGS.density, DEFAULT_SETTINGS.interfaceLayout); + applyInterfaceLayout(DEFAULT_SETTINGS.interfaceLayout, DEFAULT_SETTINGS.density); + applyAnimations(DEFAULT_SETTINGS.animationsEnabled); + void settingsHooks.onSettingsReset.emit({}); + }, + + exportSettings: (opts) => { + const json = buildExport(); + if (!opts?.forSync) { + void settingsHooks.onSettingsExport.emit({ keys: Object.keys(JSON.parse(json)) }); + } + return json; }, importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => { @@ -1095,14 +1161,20 @@ export const useSettingsStore = create()( settings.protocolOpenMode = settings.protocolMailtoOpenMode; } - // Apply settings + // Apply settings, noting which keys were taken over for the + // onSettingsImport hook. + const imported: string[] = []; + const put = (key: string, patch: Record) => { + set(patch as Partial); + imported.push(key); + }; Object.keys(settings).forEach((key) => { if (key in DEFAULT_SETTINGS) { if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { return; } if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) { - set({ sendDelaySeconds: 0 }); + put(key, { sendDelaySeconds: 0 }); return; } // Validity of the zone id itself is checked at use time @@ -1128,7 +1200,7 @@ export const useSettingsStore = create()( // unknown criterion from a newer/older client must never get // through (an unsupportedSort refusal empties the folder). if (key === 'messageListOrder') { - set({ messageListOrder: sanitizeSortLevels(settings[key]) }); + put(key, { messageListOrder: sanitizeSortLevels(settings[key]) }); return; } if (key === 'messageListOrderScope' && settings[key] !== 'inbox' && settings[key] !== 'all') { @@ -1155,10 +1227,10 @@ export const useSettingsStore = create()( if (Object.prototype.hasOwnProperty.call(incoming, opts.serverAccountId)) { merged[opts.serverAccountId] = incoming[opts.serverAccountId]; } - set({ [key]: merged }); + put(key, { [key]: merged }); return; } - set({ [key]: settings[key] }); + put(key, { [key]: settings[key] }); } }); @@ -1187,6 +1259,15 @@ export const useSettingsStore = create()( { merge: Boolean(opts?.serverAccountId) } ); + // A load from the server reports itself as a sync (loadFromServer); + // only a file import the user picked is an import. + if (!opts?.serverAccountId) { + if (settings.theme) imported.push('theme'); + if (settings.locale) imported.push('locale'); + if (settings.templates !== undefined) imported.push('templates'); + void settingsHooks.onSettingsImport.emit({ keys: imported }); + } + return true; } catch (error) { console.error('Failed to import settings:', error); @@ -1195,23 +1276,25 @@ export const useSettingsStore = create()( }, // Folder icon methods + // These map-valued preferences go through updateSetting so plugins see + // them like any other preference change. setFolderIcon: (mailboxId: string, icon: string) => { - set({ folderIcons: { ...get().folderIcons, [mailboxId]: icon } }); + get().updateSetting('folderIcons', { ...get().folderIcons, [mailboxId]: icon }); }, removeFolderIcon: (mailboxId: string) => { const { [mailboxId]: _, ...rest } = get().folderIcons; - set({ folderIcons: rest }); + get().updateSetting('folderIcons', rest); }, // Shared-calendar color override methods setSharedCalendarColor: (key: string, color: string) => { - set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } }); + get().updateSetting('sharedCalendarColors', { ...get().sharedCalendarColors, [key]: color }); }, removeSharedCalendarColor: (key: string) => { const { [key]: _, ...rest } = get().sharedCalendarColors; - set({ sharedCalendarColors: rest }); + get().updateSetting('sharedCalendarColors', rest); }, // Trusted senders methods @@ -1223,6 +1306,7 @@ export const useSettingsStore = create()( const current = get().trustedSenders; if (!current.includes(emailAddress)) { set({ trustedSenders: [...current, emailAddress] }); + void settingsHooks.onTrustedSenderChange.emit({ email: emailAddress, trusted: true }); } }, @@ -1231,9 +1315,10 @@ export const useSettingsStore = create()( const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); - set({ - trustedSenders: get().trustedSenders.filter(e => e !== emailAddress) - }); + const current = get().trustedSenders; + if (!current.includes(emailAddress)) return; + set({ trustedSenders: current.filter(e => e !== emailAddress) }); + void settingsHooks.onTrustedSenderChange.emit({ email: emailAddress, trusted: false }); }, isSenderTrusted: (email: string) => { @@ -1283,23 +1368,21 @@ export const useSettingsStore = create()( addSidebarApp: (app: SidebarApp) => { const current = get().sidebarApps; if (current.some(a => a.id === app.id)) return; - set({ sidebarApps: [...current, app] }); + get().updateSetting('sidebarApps', [...current, app]); }, updateSidebarApp: (id: string, updates: Partial>) => { - set({ - sidebarApps: get().sidebarApps.map(a => - a.id === id ? { ...a, ...updates } : a - ), - }); + get().updateSetting('sidebarApps', get().sidebarApps.map(a => + a.id === id ? { ...a, ...updates } : a + )); }, removeSidebarApp: (id: string) => { - set({ sidebarApps: get().sidebarApps.filter(a => a.id !== id) }); + get().updateSetting('sidebarApps', get().sidebarApps.filter(a => a.id !== id)); }, reorderSidebarApps: (apps: SidebarApp[]) => { - set({ sidebarApps: apps }); + get().updateSetting('sidebarApps', apps); }, // Settings sync methods @@ -1402,6 +1485,7 @@ export const useSettingsStore = create()( writeSettingsOwner(accountId); pushTemplatesAfterLoad = localTemplatesAhead(settings); syncLog('Settings loaded from server successfully'); + void settingsHooks.onSettingsSync.emit({ direction: 'pulled' }); // The per-account preferred sender identity (#507) is re-applied by // applyPreferredIdentity() in auth-store, invoked from the // loadFromServer().finally() of every login / switch / restore path, @@ -1635,6 +1719,15 @@ function applyAnimations(enabled: boolean) { } } +// Tag definitions change from the settings editor, from plugins (host-api +// writes the store directly) and from imports and server loads. Watching the +// list catches every path once instead of hooking each writer. +useSettingsStore.subscribe((state, prev) => { + if (state.emailKeywords === prev.emailKeywords) return; + if (JSON.stringify(state.emailKeywords) === JSON.stringify(prev.emailKeywords)) return; + void settingsHooks.onKeywordChange.emit({ keywords: toKeywordViews(state.emailKeywords) }); +}); + // Initialize settings on load if (typeof window !== 'undefined') { const store = useSettingsStore.getState(); @@ -1648,7 +1741,7 @@ if (typeof window !== 'undefined') { pendingSync = { username: syncUsername, serverUrl: syncServerUrl, - settings: JSON.parse(useSettingsStore.getState().exportSettings()), + settings: JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true })), }; if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(async () => { From 1ae73ceac175a5a63d17c0c31260a2e6318d0a28 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:26:39 +0200 Subject: [PATCH 038/147] feat: tell plugins when events are opened, created, changed, deleted, answered or imported, and let them stop changes --- .../__tests__/event-modal-open-hooks.test.tsx | 66 +++ components/calendar/calendar-app.tsx | 10 +- components/calendar/event-modal.tsx | 28 ++ .../calendar-store-event-hooks.test.ts | 276 ++++++++++++ stores/calendar-store.ts | 410 ++++++++++-------- 5 files changed, 603 insertions(+), 187 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-open-hooks.test.tsx create mode 100644 stores/__tests__/calendar-store-event-hooks.test.ts diff --git a/components/calendar/__tests__/event-modal-open-hooks.test.tsx b/components/calendar/__tests__/event-modal-open-hooks.test.tsx new file mode 100644 index 000000000..00c4e44ca --- /dev/null +++ b/components/calendar/__tests__/event-modal-open-hooks.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventModal } from '../event-modal'; +import { calendarFormHooks, calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Opening an event for reading fires onCalendarEventOpen once; opening the +// form (a new event, or Edit on an open one) fires onCalendarEventFormOpen. + +const calendars = [ + { id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, +] as unknown as Calendar[]; + +const existing = { + id: 'e1', + uid: 'uid-1', + title: 'Planning', + start: '2026-10-12T10:00:00', + duration: 'PT1H', + timeZone: null, + showWithoutTime: false, + calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +afterEach(() => clearAllHooks()); + +describe('EventModal open hooks', () => { + it('reports an existing event opened for reading, and the form when Edit is pressed', async () => { + const opened = vi.fn(); + const formOpened = vi.fn(); + calendarHooks.onCalendarEventOpen.register('t', opened); + calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened); + + render(); + + await waitFor(() => expect(opened).toHaveBeenCalledTimes(1)); + expect(opened.mock.calls[0][0]).toMatchObject({ id: 'e1', title: 'Planning', calendarId: 'cal-1' }); + expect(formOpened).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('events.edit')); + await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1)); + expect(formOpened.mock.calls[0][0]).toMatchObject({ title: 'Planning', calendarId: 'cal-1', uid: 'uid-1', isAllDay: false }); + expect(opened).toHaveBeenCalledTimes(1); + }); + + it('reports the form for a new event, and no event open', async () => { + const opened = vi.fn(); + const formOpened = vi.fn(); + calendarHooks.onCalendarEventOpen.register('t', opened); + calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened); + + render( + , + ); + + await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1)); + expect(formOpened.mock.calls[0][0]).toMatchObject({ calendarId: 'cal-1', uid: undefined, attendees: [] }); + expect(opened).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 9d5b6e4e6..df4e7f532 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -97,6 +97,8 @@ import { findMeetingLink } from "@/lib/event-links"; import { useDocumentTitle } from "@/hooks/use-document-title"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; +import { calendarHooks } from "@/lib/plugin-hooks"; +import { eventToView } from "@/lib/plugin-projection"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -707,6 +709,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setDetailPinned(true); setDetailEvent(event); setDetailAnchorRect(anchorRect); + // A click opens the event's card; the hover preview does not count as + // opening it. The editor reports its own opens (event-modal). + void calendarHooks.onCalendarEventOpen.emit(eventToView(event)); }, [isMobile, closeDetail, openEditModal]); const { @@ -1206,7 +1211,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = delete (newEventData as Record).uid; delete (newEventData as Record).recurrenceId; try { - await createEvent(client, newEventData, sendScheduling); + // null when the save failed or a plugin stopped it: either way + // the truncated master has to be restored. + const created = await createEvent(client, newEventData, sendScheduling); + if (!created) throw new Error("The new series was not created"); } catch (createError) { debug.error("Failed to create new series, rolling back master truncation:", createError); try { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 340ba2e52..14b2d26aa 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -40,6 +40,7 @@ import { type CalendarEventFormSavePatch, } from "@/lib/plugin-hooks"; import type { ConflictWarning } from "@/lib/plugin-types"; +import { eventToView } from "@/lib/plugin-projection"; export interface PendingEventPreview { start: Date; @@ -418,6 +419,33 @@ export function EventModal({ return () => { cancelled = true; clearTimeout(t); }; }, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]); + // Plugins hear about an existing event opened for reading once per event, + // and about the form each time it opens (new event, or view -> edit). + const reportedOpenRef = useRef(null); + useEffect(() => { + if (!event || mode !== "view" || reportedOpenRef.current === event.id) return; + reportedOpenRef.current = event.id; + void calendarHooks.onCalendarEventOpen.emit(eventToView(event)); + }, [event, mode]); + + useEffect(() => { + if (mode !== "edit" || calendarFormHooks.onCalendarEventFormOpen.size === 0) return; + void calendarFormHooks.onCalendarEventFormOpen.emit({ + title, + description, + start: allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`, + end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`, + isAllDay: allDay, + location, + virtualLocation, + calendarId, + uid: event?.uid, + attendees: attendees.map((a) => a.email), + }); + // Only the opening counts; the fields are read as they are at that moment. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + // Report live preview to parent for grid outline useEffect(() => { if (!onPreviewChange || isEdit) return; diff --git a/stores/__tests__/calendar-store-event-hooks.test.ts b/stores/__tests__/calendar-store-event-hooks.test.ts new file mode 100644 index 000000000..3568c54f2 --- /dev/null +++ b/stores/__tests__/calendar-store-event-hooks.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useCalendarStore } from '../calendar-store'; +import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// Plugins see event creates, edits, deletes, RSVPs and imports through the +// calendar hooks: the before-hooks run ahead of the server call and can stop +// the action, the observers fire once it succeeded. + +function event(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', + uid: 'uid-1', + title: 'Planning', + start: '2026-09-08T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + calendarIds: { c1: true }, + participants: { + p1: { '@type': 'Participant', name: 'Me', email: 'me@example.com', participationStatus: 'needs-action' }, + }, + ...overrides, + } as CalendarEvent; +} + +function fakeClient(overrides: Partial> = {}) { + return { + createCalendarEvent: vi.fn(async (e: Partial) => ({ ...e, id: 'new-1' })), + updateCalendarEvent: vi.fn().mockResolvedValue(undefined), + deleteCalendarEvent: vi.fn().mockResolvedValue(undefined), + queryAllCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvents: vi.fn().mockResolvedValue([]), + batchCreateCalendarEvents: vi.fn(async (batch: unknown[]) => ({ + created: batch.map((_, i) => ({ id: `imp-${i}` })), failed: [], notCreated: {}, + })), + ...overrides, + } as unknown as IJMAPClient & Record>; +} + +beforeEach(() => { + clearAllHooks(); + useCalendarStore.getState().clearState(); + useCalendarStore.setState({ events: [], calendars: [], error: null, dateRange: null }); +}); + +describe('createEvent hooks', () => { + it('asks onBeforeEventCreate before the server call and reports the created event', async () => { + const client = fakeClient(); + const order: string[] = []; + calendarHooks.onBeforeEventCreate.register('t', vi.fn((view) => { + order.push('before'); + expect(client.createCalendarEvent).not.toHaveBeenCalled(); + expect(view).toMatchObject({ title: 'Planning', calendarId: 'c1' }); + return true; + })); + const after = vi.fn(); + calendarHooks.onAfterEventCreate.register('t', after); + + const created = await useCalendarStore.getState().createEvent(client, { title: 'Planning', start: '2026-09-08T10:00:00', calendarIds: { c1: true } }); + + expect(order).toEqual(['before']); + expect(created?.id).toBe('new-1'); + expect(after).toHaveBeenCalledTimes(1); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'new-1', title: 'Planning', calendarId: 'c1' }); + }); + + it('creates nothing when a plugin says no', async () => { + const client = fakeClient(); + calendarHooks.onBeforeEventCreate.register('t', () => false); + const after = vi.fn(); + calendarHooks.onAfterEventCreate.register('t', after); + + const created = await useCalendarStore.getState().createEvent(client, { title: 'Nope' }); + + expect(created).toBeNull(); + expect(client.createCalendarEvent).not.toHaveBeenCalled(); + expect(useCalendarStore.getState().events).toEqual([]); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a create the server refused', async () => { + const client = fakeClient({ createCalendarEvent: vi.fn().mockRejectedValue(new Error('boom')) }); + const after = vi.fn(); + calendarHooks.onAfterEventCreate.register('t', after); + await useCalendarStore.getState().createEvent(client, { title: 'X' }); + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe('updateEvent hooks', () => { + it('passes id and patch to onBeforeEventUpdate and the changed event to onAfterEventUpdate', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [event()] }); + const before = vi.fn(() => { + expect(client.updateCalendarEvent).not.toHaveBeenCalled(); + return true; + }); + calendarHooks.onBeforeEventUpdate.register('t', before); + const after = vi.fn(); + calendarHooks.onAfterEventUpdate.register('t', after); + + await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' }); + + expect(before).toHaveBeenCalledWith({ eventId: 'e1', patch: { title: 'Renamed' } }); + expect(after).toHaveBeenCalledTimes(1); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'e1', title: 'Renamed', start: '2026-09-08T10:00:00' }); + }); + + it('leaves the event alone when a plugin says no', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [event()] }); + calendarHooks.onBeforeEventUpdate.register('t', () => false); + const after = vi.fn(); + calendarHooks.onAfterEventUpdate.register('t', after); + + await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' }); + + expect(client.updateCalendarEvent).not.toHaveBeenCalled(); + expect(useCalendarStore.getState().events[0].title).toBe('Planning'); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a single-occurrence edit once, with the edited occurrence', async () => { + const client = fakeClient(); + const occ = event({ + id: 'maaaaab', originalId: 'maaaaab', baseEventId: 'b', uid: 'series', + recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] as unknown as CalendarEvent['recurrenceRules'], + }); + useCalendarStore.setState({ events: [occ] }); + const after = vi.fn(); + calendarHooks.onAfterEventUpdate.register('t', after); + + await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Just this one' }); + + expect(after).toHaveBeenCalledTimes(1); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'maaaaab', title: 'Just this one', isRecurring: true }); + }); + + it('does not report an update the server refused', async () => { + const client = fakeClient({ updateCalendarEvent: vi.fn().mockRejectedValue(new Error('boom')) }); + useCalendarStore.setState({ events: [event()] }); + const after = vi.fn(); + calendarHooks.onAfterEventUpdate.register('t', after); + await expect(useCalendarStore.getState().updateEvent(client, 'e1', { title: 'X' })).rejects.toThrow('boom'); + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe('deleteEvent hooks', () => { + it('asks first and reports the deleted event with its title', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [event()] }); + const before = vi.fn(() => { + expect(client.deleteCalendarEvent).not.toHaveBeenCalled(); + return true; + }); + calendarHooks.onBeforeEventDelete.register('t', before); + const after = vi.fn(); + calendarHooks.onAfterEventDelete.register('t', after); + + await useCalendarStore.getState().deleteEvent(client, 'e1'); + + expect(before).toHaveBeenCalledWith({ eventId: 'e1', title: 'Planning' }); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith({ eventId: 'e1', title: 'Planning' }); + }); + + it('keeps the event when a plugin says no', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [event()] }); + calendarHooks.onBeforeEventDelete.register('t', () => false); + const after = vi.fn(); + calendarHooks.onAfterEventDelete.register('t', after); + + await useCalendarStore.getState().deleteEvent(client, 'e1'); + + expect(client.deleteCalendarEvent).not.toHaveBeenCalled(); + expect(useCalendarStore.getState().events).toHaveLength(1); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a delete the server refused', async () => { + const client = fakeClient({ deleteCalendarEvent: vi.fn().mockRejectedValue(new Error('boom')) }); + useCalendarStore.setState({ events: [event()] }); + const after = vi.fn(); + calendarHooks.onAfterEventDelete.register('t', after); + await expect(useCalendarStore.getState().deleteEvent(client, 'e1')).rejects.toThrow('boom'); + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe('rsvpEvent hooks', () => { + it('asks first and reports the answer', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [event()] }); + const before = vi.fn(() => { + expect(client.updateCalendarEvent).not.toHaveBeenCalled(); + return true; + }); + calendarHooks.onBeforeEventRsvp.register('t', before); + const after = vi.fn(); + calendarHooks.onEventRsvp.register('t', after); + + await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted'); + + expect(before).toHaveBeenCalledWith({ eventId: 'e1', status: 'accepted' }); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith({ eventId: 'e1', status: 'accepted' }); + }); + + it('sends no answer when a plugin says no', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [event()] }); + calendarHooks.onBeforeEventRsvp.register('t', () => false); + const after = vi.fn(); + calendarHooks.onEventRsvp.register('t', after); + + await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted'); + + expect(client.updateCalendarEvent).not.toHaveBeenCalled(); + expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('needs-action'); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report an answer the server refused', async () => { + const client = fakeClient({ updateCalendarEvent: vi.fn().mockRejectedValue(new Error('boom')) }); + useCalendarStore.setState({ events: [event()] }); + const after = vi.fn(); + calendarHooks.onEventRsvp.register('t', after); + await expect(useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted')).rejects.toThrow('boom'); + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe('importEvents hooks', () => { + const file = [ + { uid: 'a', title: 'A', start: '2026-09-08T10:00:00' }, + { uid: 'b', title: 'B', start: '2026-09-09T10:00:00' }, + { uid: 'a', title: 'A again', start: '2026-09-08T10:00:00' }, + ] as Partial[]; + + it('asks with the event count and reports what was imported and skipped', async () => { + const client = fakeClient(); + const before = vi.fn(() => { + expect(client.batchCreateCalendarEvents).not.toHaveBeenCalled(); + return true; + }); + calendarHooks.onBeforeEventsImport.register('t', before); + const after = vi.fn(); + calendarHooks.onEventsImport.register('t', after); + + const count = await useCalendarStore.getState().importEvents(client, file, 'c1'); + + expect(count).toBe(2); + expect(before).toHaveBeenCalledWith({ calendarId: 'c1', count: 3 }); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith({ calendarId: 'c1', imported: 2, skipped: 1 }); + }); + + it('imports nothing when a plugin says no', async () => { + const client = fakeClient(); + calendarHooks.onBeforeEventsImport.register('t', () => false); + const after = vi.fn(); + calendarHooks.onEventsImport.register('t', after); + + const count = await useCalendarStore.getState().importEvents(client, file, 'c1'); + + expect(count).toBe(0); + expect(client.getCalendarEvents).not.toHaveBeenCalled(); + expect(client.batchCreateCalendarEvents).not.toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 13fd76342..74b2d2583 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -26,6 +26,8 @@ import { apiFetch } from '@/lib/browser-navigation'; import { explainServerAuthError } from '@/lib/server-auth-status'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; import { getClientByLocalAccountId } from './client-registry'; +import { calendarHooks } from '@/lib/plugin-hooks'; +import { eventToView } from '@/lib/plugin-projection'; /** * When the Pro shell aggregates calendars/events from every connected @@ -792,6 +794,9 @@ export const useCalendarStore = create()( }, createEvent: async (client, event, sendSchedulingMessages) => { + if (!(await calendarHooks.onBeforeEventCreate.intercept(eventToView({ id: '', ...event } as CalendarEvent)))) { + return null; + } set({ error: null }); try { // Resolve shared calendar context from calendarIds. Also pin the @@ -869,6 +874,7 @@ export const useCalendarStore = create()( } set((state) => ({ events: [...state.events, mappedCreated] })); + void calendarHooks.onAfterEventCreate.emit(eventToView(mappedCreated)); // Invitation emails are sent by the server: `sendSchedulingMessages` // on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. // Sending a client-side iMIP copy here produced duplicate emails. @@ -883,6 +889,7 @@ export const useCalendarStore = create()( }, updateEvent: async (client, id, updates, sendSchedulingMessages) => { + if (!(await calendarHooks.onBeforeEventUpdate.intercept({ eventId: id, patch: updates }))) return; set({ error: null }); try { // Resolve shared event IDs and client-side expanded occurrence IDs @@ -915,6 +922,9 @@ export const useCalendarStore = create()( } else { await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); } + // The event as it is after the change, before a refetch reshuffles + // occurrence ids: for a single-occurrence edit this is that occurrence. + let updatedEvent: CalendarEvent | undefined; set((state) => ({ events: state.events.map(e => { if (e.id !== id) return e; @@ -940,9 +950,14 @@ export const useCalendarStore = create()( new Date(merged.utcStart).getTime() + durationMinutes * 60000, ).toISOString(); } + updatedEvent = merged; return merged; }), })); + if (calendarHooks.onAfterEventUpdate.size > 0) { + const after = updatedEvent ?? ({ ...(storeEvent ?? {}), ...updates, id } as CalendarEvent); + void calendarHooks.onAfterEventUpdate.emit(eventToView(after)); + } if (target.isOccurrence || target.isBrowserOccurrence || hasExpandedOccurrencesOf(get().events, realId, target)) { await refetchAfterOccurrenceMutation(); } @@ -965,6 +980,7 @@ export const useCalendarStore = create()( set({ error: 'Invalid participant ID' }); throw new Error('Invalid participant ID'); } + if (!(await calendarHooks.onBeforeEventRsvp.intercept({ eventId, status }))) return; try { // Resolve shared event IDs and client-side expanded occurrence IDs. // Scope 'series' answers for the whole series, so an expanded @@ -1011,6 +1027,7 @@ export const useCalendarStore = create()( }; }), })); + void calendarHooks.onEventRsvp.emit({ eventId, status }); // Only the answered event was updated above: after a series answer // the other occurrences in view still show the old status, after an // occurrence answer the synthetic ids / overrides are stale. @@ -1025,194 +1042,15 @@ export const useCalendarStore = create()( }, importEvents: async (client, events, calendarId) => { - // Resolve shared calendar IDs - const cal = get().calendars.find(c => c.id === calendarId); - const realCalendarId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); - const targetAccountId = cal?.accountId; - client = resolveAccountClient(client, cal?.localAccountId); - - // Drop within-file UID duplicates first (first occurrence wins). - // CalendarEvent/parse already merges master + RECURRENCE-ID overrides - // into one object, so anything still sharing a UID here is a corrupt - // feed - creating both either fails ("UID already exists") or stores - // two events under one UID, depending on the Stalwart version. - const seenUids = new Set(); - let eventsToProcess = events.filter((e) => { - if (!e.uid) return true; - if (seenUids.has(e.uid)) return false; - seenUids.add(e.uid); - return true; - }); - - // Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars. - // - Events already in the target calendar → skip (true duplicates) - // - Events in other calendars → link to target calendar via calendarIds update - // - New events → create as normal - let linked = 0; - try { - const allServerEvents = await client.getCalendarEvents(undefined, targetAccountId); - const uidToEvent = new Map }>(); - for (const e of allServerEvents) { - if (e.uid) { - uidToEvent.set(e.uid, { - id: (e as CalendarEvent).originalId || e.id, - calendarIds: (e as CalendarEvent).originalCalendarIds || e.calendarIds || {}, - }); - } - } - - const newEvents: Partial[] = []; - const eventsToLink: { eventId: string; calendarIds: Record }[] = []; - - for (const e of eventsToProcess) { - if (!e.uid || !uidToEvent.has(e.uid)) { - // UID doesn't exist on server - create it - newEvents.push(e); - } else { - const existing = uidToEvent.get(e.uid)!; - if (existing.calendarIds[realCalendarId]) { - // Already in target calendar - skip - continue; - } - // Exists in another calendar - link to target calendar - eventsToLink.push({ - eventId: existing.id, - calendarIds: { ...existing.calendarIds, [realCalendarId]: true }, - }); - } - } - - // Batch-link existing events to the target calendar - for (const { eventId, calendarIds } of eventsToLink) { - try { - await client.updateCalendarEvent(eventId, { calendarIds } as Partial, undefined, targetAccountId); - linked++; - } catch (err) { - debug.warn('calendar', `Import: failed to link event ${eventId} to target calendar:`, err); - } - } - - if (linked > 0) { - debug.log('calendar', `Import: linked ${linked} existing events to target calendar`); - } - const skipped = eventsToProcess.length - newEvents.length - eventsToLink.length; - if (skipped > 0) { - debug.log('calendar', `Import: skipped ${skipped} events already in target calendar`); - } - eventsToProcess = newEvents; - } catch (error) { - debug.warn('calendar', 'Could not fetch existing events for deduplication, proceeding without:', error); - } - - // Prepare all events for batch creation - const prepared: Partial[] = []; - for (const event of eventsToProcess) { - const src = sanitizeOutgoingCalendarEventData(event as Partial); - let cleanParticipants: Record | null = null; - if (src.participants) { - cleanParticipants = {}; - for (const [key, p] of Object.entries(src.participants)) { - const participant: Record = { - '@type': 'Participant', - name: p.name, - email: p.email, - // calendarAddress carries the scheduling address in jscalendarbis - // (Stalwart); sendTo is retired there, so it only serves as a - // fallback source for events parsed from legacy RFC 8984 data. - calendarAddress: p.calendarAddress || p.sendTo?.imip, - description: p.description, - kind: p.kind, - roles: p.roles, - participationStatus: p.participationStatus, - participationComment: p.participationComment, - expectReply: p.expectReply, - scheduleAgent: p.scheduleAgent, - scheduleForceSend: p.scheduleForceSend, - scheduleId: p.scheduleId, - delegatedTo: p.delegatedTo, - delegatedFrom: p.delegatedFrom, - memberOf: p.memberOf, - locationId: p.locationId, - language: p.language, - links: p.links, - }; - Object.keys(participant).forEach(k => { - if (participant[k] === undefined || participant[k] === null) delete participant[k]; - }); - cleanParticipants[key] = participant as unknown as CalendarParticipant; - } - } - - const data: Partial = { - calendarIds: { [realCalendarId]: true }, - uid: src.uid, - title: src.title, - description: src.description, - descriptionContentType: src.descriptionContentType, - start: src.start, - duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration, - timeZone: src.showWithoutTime ? null : src.timeZone, - showWithoutTime: src.showWithoutTime, - status: src.status, - freeBusyStatus: src.freeBusyStatus, - privacy: src.privacy, - color: src.color, - keywords: src.keywords, - categories: src.categories, - locale: src.locale, - // Stalwart derives the iCalendar ORGANIZER solely from - // organizerCalendarAddress (replyTo is retired in jscalendarbis); - // dropping it here would strip the ORGANIZER from imported invites - // and break RSVP replies afterwards. - organizerCalendarAddress: src.organizerCalendarAddress || src.replyTo?.imip, - locations: src.locations, - virtualLocations: src.virtualLocations, - links: src.links, - recurrenceRules: src.recurrenceRules, - recurrenceOverrides: src.recurrenceOverrides, - excludedRecurrenceRules: src.excludedRecurrenceRules, - alerts: src.alerts, - participants: cleanParticipants, - }; - Object.keys(data).forEach(k => { - const v = (data as Record)[k]; - if (v === undefined || v === null) delete (data as Record)[k]; - }); - prepared.push(data); - } - - if (prepared.length === 0) return linked; - - // Batch create in chunks of 50 to avoid oversized requests - const BATCH_SIZE = 50; - let imported = 0; - for (let i = 0; i < prepared.length; i += BATCH_SIZE) { - const batch = prepared.slice(i, i + BATCH_SIZE); - try { - const { created, failed, notCreated } = await client.batchCreateCalendarEvents(batch, targetAccountId); - imported += created.length; - if (failed.length > 0) { - const first = Object.values(notCreated)[0]; - const reason = first?.description || first?.type || 'unknown error'; - debug.warn('calendar', `Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed (${reason})`); - set({ error: `${failed.length} event(s) could not be imported: ${reason}` }); - } - } catch (error) { - debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error); - } - } - - // Re-fetch all events properly so the store state is consistent - // (with recurrence expansion, multi-account mapping, etc.) - const { dateRange } = get(); - if (dateRange) { - await get().fetchEvents(client, dateRange.start, dateRange.end); - } - - return imported + linked; + if (!(await calendarHooks.onBeforeEventsImport.intercept({ calendarId, count: events.length }))) return 0; + const imported = await importEventsUnhooked(client, events, calendarId); + void calendarHooks.onEventsImport.emit({ calendarId, imported, skipped: Math.max(0, events.length - imported) }); + return imported; }, deleteEvent: async (client, id, sendSchedulingMessages) => { + const title = get().events.find((e) => e.id === id)?.title || ''; + if (!(await calendarHooks.onBeforeEventDelete.intercept({ eventId: id, title }))) return; set({ error: null }); try { // Resolve shared event IDs and client-side expanded occurrence IDs @@ -1242,6 +1080,7 @@ export const useCalendarStore = create()( events: state.events.filter(e => e.id !== id), selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, })); + void calendarHooks.onAfterEventDelete.emit({ eventId: id, title }); if (target.isOccurrence || target.isBrowserOccurrence || hadOccurrences) { await refetchAfterOccurrenceMutation(); } @@ -1684,7 +1523,7 @@ export const useCalendarStore = create()( // Import new events (importEvents will re-fetch all events at the end) if (eventsToImport.length > 0) { - await get().importEvents(client, eventsToImport, sub.calendarId); + await importEventsUnhooked(client, eventsToImport, sub.calendarId); } else { // No new events to import, but stale ones may have been deleted // Re-fetch to reflect deletions @@ -1783,3 +1622,202 @@ export const useCalendarStore = create()( } ) ); + +/** + * Imports events into a calendar without the plugin import hooks. The + * store's importEvents wraps it with them; a subscription refresh calls it + * directly, since syncing a feed is not the user importing a file. + */ +async function importEventsUnhooked( + client: IJMAPClient, + events: Partial[], + calendarId: string, +): Promise { + const get = useCalendarStore.getState; + const set = useCalendarStore.setState; + // Resolve shared calendar IDs + const cal = get().calendars.find(c => c.id === calendarId); + const realCalendarId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); + const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); + + // Drop within-file UID duplicates first (first occurrence wins). + // CalendarEvent/parse already merges master + RECURRENCE-ID overrides + // into one object, so anything still sharing a UID here is a corrupt + // feed - creating both either fails ("UID already exists") or stores + // two events under one UID, depending on the Stalwart version. + const seenUids = new Set(); + let eventsToProcess = events.filter((e) => { + if (!e.uid) return true; + if (seenUids.has(e.uid)) return false; + seenUids.add(e.uid); + return true; + }); + + // Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars. + // - Events already in the target calendar → skip (true duplicates) + // - Events in other calendars → link to target calendar via calendarIds update + // - New events → create as normal + let linked = 0; + try { + const allServerEvents = await client.getCalendarEvents(undefined, targetAccountId); + const uidToEvent = new Map }>(); + for (const e of allServerEvents) { + if (e.uid) { + uidToEvent.set(e.uid, { + id: (e as CalendarEvent).originalId || e.id, + calendarIds: (e as CalendarEvent).originalCalendarIds || e.calendarIds || {}, + }); + } + } + + const newEvents: Partial[] = []; + const eventsToLink: { eventId: string; calendarIds: Record }[] = []; + + for (const e of eventsToProcess) { + if (!e.uid || !uidToEvent.has(e.uid)) { + // UID doesn't exist on server - create it + newEvents.push(e); + } else { + const existing = uidToEvent.get(e.uid)!; + if (existing.calendarIds[realCalendarId]) { + // Already in target calendar - skip + continue; + } + // Exists in another calendar - link to target calendar + eventsToLink.push({ + eventId: existing.id, + calendarIds: { ...existing.calendarIds, [realCalendarId]: true }, + }); + } + } + + // Batch-link existing events to the target calendar + for (const { eventId, calendarIds } of eventsToLink) { + try { + await client.updateCalendarEvent(eventId, { calendarIds } as Partial, undefined, targetAccountId); + linked++; + } catch (err) { + debug.warn('calendar', `Import: failed to link event ${eventId} to target calendar:`, err); + } + } + + if (linked > 0) { + debug.log('calendar', `Import: linked ${linked} existing events to target calendar`); + } + const skipped = eventsToProcess.length - newEvents.length - eventsToLink.length; + if (skipped > 0) { + debug.log('calendar', `Import: skipped ${skipped} events already in target calendar`); + } + eventsToProcess = newEvents; + } catch (error) { + debug.warn('calendar', 'Could not fetch existing events for deduplication, proceeding without:', error); + } + + // Prepare all events for batch creation + const prepared: Partial[] = []; + for (const event of eventsToProcess) { + const src = sanitizeOutgoingCalendarEventData(event as Partial); + let cleanParticipants: Record | null = null; + if (src.participants) { + cleanParticipants = {}; + for (const [key, p] of Object.entries(src.participants)) { + const participant: Record = { + '@type': 'Participant', + name: p.name, + email: p.email, + // calendarAddress carries the scheduling address in jscalendarbis + // (Stalwart); sendTo is retired there, so it only serves as a + // fallback source for events parsed from legacy RFC 8984 data. + calendarAddress: p.calendarAddress || p.sendTo?.imip, + description: p.description, + kind: p.kind, + roles: p.roles, + participationStatus: p.participationStatus, + participationComment: p.participationComment, + expectReply: p.expectReply, + scheduleAgent: p.scheduleAgent, + scheduleForceSend: p.scheduleForceSend, + scheduleId: p.scheduleId, + delegatedTo: p.delegatedTo, + delegatedFrom: p.delegatedFrom, + memberOf: p.memberOf, + locationId: p.locationId, + language: p.language, + links: p.links, + }; + Object.keys(participant).forEach(k => { + if (participant[k] === undefined || participant[k] === null) delete participant[k]; + }); + cleanParticipants[key] = participant as unknown as CalendarParticipant; + } + } + + const data: Partial = { + calendarIds: { [realCalendarId]: true }, + uid: src.uid, + title: src.title, + description: src.description, + descriptionContentType: src.descriptionContentType, + start: src.start, + duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration, + timeZone: src.showWithoutTime ? null : src.timeZone, + showWithoutTime: src.showWithoutTime, + status: src.status, + freeBusyStatus: src.freeBusyStatus, + privacy: src.privacy, + color: src.color, + keywords: src.keywords, + categories: src.categories, + locale: src.locale, + // Stalwart derives the iCalendar ORGANIZER solely from + // organizerCalendarAddress (replyTo is retired in jscalendarbis); + // dropping it here would strip the ORGANIZER from imported invites + // and break RSVP replies afterwards. + organizerCalendarAddress: src.organizerCalendarAddress || src.replyTo?.imip, + locations: src.locations, + virtualLocations: src.virtualLocations, + links: src.links, + recurrenceRules: src.recurrenceRules, + recurrenceOverrides: src.recurrenceOverrides, + excludedRecurrenceRules: src.excludedRecurrenceRules, + alerts: src.alerts, + participants: cleanParticipants, + }; + Object.keys(data).forEach(k => { + const v = (data as Record)[k]; + if (v === undefined || v === null) delete (data as Record)[k]; + }); + prepared.push(data); + } + + if (prepared.length === 0) return linked; + + // Batch create in chunks of 50 to avoid oversized requests + const BATCH_SIZE = 50; + let imported = 0; + for (let i = 0; i < prepared.length; i += BATCH_SIZE) { + const batch = prepared.slice(i, i + BATCH_SIZE); + try { + const { created, failed, notCreated } = await client.batchCreateCalendarEvents(batch, targetAccountId); + imported += created.length; + if (failed.length > 0) { + const first = Object.values(notCreated)[0]; + const reason = first?.description || first?.type || 'unknown error'; + debug.warn('calendar', `Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed (${reason})`); + set({ error: `${failed.length} event(s) could not be imported: ${reason}` }); + } + } catch (error) { + debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error); + } + } + + // Re-fetch all events properly so the store state is consistent + // (with recurrence expansion, multi-account mapping, etc.) + const { dateRange } = get(); + if (dateRange) { + await get().fetchEvents(client, dateRange.start, dateRange.end); + } + + return imported + linked; +} From c5dc26325988d0d46921151204f6046489de02f4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:27:34 +0200 Subject: [PATCH 039/147] fix: stop plugins from blocking or stalling sign-out --- .../__tests__/auth-store-logout-hooks.test.ts | 46 +++++++++++++++++++ stores/auth-store.ts | 34 +++++++------- 2 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 stores/__tests__/auth-store-logout-hooks.test.ts diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts new file mode 100644 index 000000000..810e90e93 --- /dev/null +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import * as browserNavigation from '@/lib/browser-navigation'; +import { authHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { useAuthStore, LOGOUT_HOOK_BUDGET_MS } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +describe('sign-out with plugins', () => { + beforeEach(() => { + vi.restoreAllMocks(); + clearAllHooks(); + sessionStorage.clear(); + localStorage.clear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: null, activeAccountId: null }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('signs out even when a plugin answers false before sign-out', async () => { + const before = vi.fn().mockReturnValue(false); + const after = vi.fn(); + authHooks.onBeforeLogout.register('p', before); + authHooks.onAfterLogout.register('p', after); + + await useAuthStore.getState().logout(); + + expect(before).toHaveBeenCalledWith({ accountId: 'all' }); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(after).toHaveBeenCalledWith({ accountId: 'all' }); + }); + + it('does not wait on a plugin that never answers', async () => { + vi.useFakeTimers(); + authHooks.onBeforeLogout.register('p', () => new Promise(() => {})); + + const done = useAuthStore.getState().logout(); + await vi.advanceTimersByTimeAsync(LOGOUT_HOOK_BUDGET_MS + 10); + await done; + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index b2c5a8b79..389a05234 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1243,6 +1243,20 @@ function performFullLogout(set: (state: Partial) => void): void { broadcastSignOut(); } +/** + * How long sign-out waits for plugins to tidy up. Plugins observe sign-out; + * they cannot stop it or hold it up, or a plugin could keep the user signed + * in on a shared computer. + */ +export const LOGOUT_HOOK_BUDGET_MS = 2000; + +function beforeLogoutHooks(accountId: string): Promise { + return Promise.race([ + authHooks.onBeforeLogout.emit({ accountId }), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -2080,13 +2094,7 @@ export const useAuthStore = create()( const userInitiated = options?.endProviderSession !== false; const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account); - const ok = await authHooks.onBeforeLogout.intercept({ - accountId: accountId ?? 'all', - }); - - if(!ok){ - return; - } + await beforeLogoutHooks(accountId ?? 'all'); // From here on nothing may renew this slot's session; the cleanup // below reopens it once the credentials are gone. @@ -2111,7 +2119,7 @@ export const useAuthStore = create()( await useSettingsStore.getState().flushSync(); useSettingsStore.getState().disableSync(); - await authHooks.onAfterLogout.emit({ + void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', }); @@ -2234,13 +2242,7 @@ export const useAuthStore = create()( }, logoutAll: async () => { - const ok = await authHooks.onBeforeLogout.intercept({ - accountId: 'all' - }); - - if(!ok){ - return; - } + await beforeLogoutHooks('all'); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; @@ -2272,7 +2274,7 @@ export const useAuthStore = create()( accountStore.removeAccount(account.id); } - await authHooks.onAfterLogout.emit({ + void authHooks.onAfterLogout.emit({ accountId: 'all' }); From 8ec7dc54abf8b3eb87098c081616e46657bb8efb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:27:50 +0200 Subject: [PATCH 040/147] feat: tell plugins when contacts are opened, selected, moved or imported and when groups change --- .../__tests__/contact-detail.test.tsx | 21 +++ components/contacts/contact-detail.tsx | 12 ++ stores/__tests__/contact-store-hooks.test.ts | 144 ++++++++++++++++++ stores/contact-store.ts | 49 +++++- 4 files changed, 222 insertions(+), 4 deletions(-) diff --git a/components/contacts/__tests__/contact-detail.test.tsx b/components/contacts/__tests__/contact-detail.test.tsx index 27f5ce962..a908f69f9 100644 --- a/components/contacts/__tests__/contact-detail.test.tsx +++ b/components/contacts/__tests__/contact-detail.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ContactDetail } from '../contact-detail'; import type { ContactCard } from '@/lib/jmap/types'; +import { contactHooks, clearAllHooks } from '@/lib/plugin-hooks'; beforeEach(() => { Object.assign(navigator, { @@ -65,3 +66,23 @@ describe('ContactDetail', () => { expect(onDelete).toHaveBeenCalledOnce(); }); }); + +describe('ContactDetail plugin hooks', () => { + beforeEach(() => clearAllHooks()); + + it('tells plugins once when a contact is opened, again for another one', async () => { + const opened = vi.fn(); + contactHooks.onContactOpen.register('test', opened); + + const { rerender } = render(); + // Same contact with changed data: still the same open contact. + rerender(); + rerender(); + rerender(); + await Promise.resolve(); + + expect(opened).toHaveBeenCalledTimes(2); + expect(opened.mock.calls[0][0]).toMatchObject({ id: '1', fullName: 'Alice Smith', emails: ['alice@example.com'] }); + expect(opened.mock.calls[1][0]).toMatchObject({ id: '2' }); + }); +}); diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index babdfc65b..4f048fc72 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -16,6 +16,8 @@ import { exportContact } from "./contact-export"; import { printContact } from "./contact-print"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { contactHooks } from "@/lib/plugin-hooks"; +import { contactToView } from "@/lib/plugin-projection"; type MoreItem = | { @@ -124,6 +126,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0; + // Tell plugins once per contact shown here, not on every edit of that card. + const latestContact = useRef(contact); + useEffect(() => { latestContact.current = contact; }); + const openedId = contact?.id; + useEffect(() => { + const opened = latestContact.current; + if (!openedId || !opened) return; + void contactHooks.onContactOpen.emit(contactToView(opened)); + }, [openedId]); + if (!contact) { return (
diff --git a/stores/__tests__/contact-store-hooks.test.ts b/stores/__tests__/contact-store-hooks.test.ts index 88cfc6ae7..b4ec555b5 100644 --- a/stores/__tests__/contact-store-hooks.test.ts +++ b/stores/__tests__/contact-store-hooks.test.ts @@ -249,3 +249,147 @@ describe('contact-store plugin hooks: contact CRUD', () => { }); }); }); + +describe('contact-store plugin hooks: groups, import, move, selection', () => { + beforeEach(() => { + clearAllHooks(); + useContactStore.setState({ + contacts: [], addressBooks: [], selectedContactId: null, error: null, + supportsSync: true, selectedContactIds: new Set(), lastSelectedContactId: null, + }); + }); + + it('reports created, renamed and deleted groups', async () => { + const client = makeClient({ + createContact: vi.fn(async (c: Partial) => ({ ...c, id: 'g1' } as ContactCard)), + }); + const changes = vi.fn(); + contactHooks.onContactGroupChange.register('test', changes); + + await useContactStore.getState().createGroup(client, 'Team', []); + await useContactStore.getState().updateGroup(client, 'g1', 'Crew'); + await useContactStore.getState().deleteGroup(client, 'g1'); + await flush(); + + expect(changes.mock.calls.map(c => c[0])).toEqual([ + { groupId: 'g1', change: 'created' }, + { groupId: 'g1', change: 'updated' }, + { groupId: 'g1', change: 'deleted' }, + ]); + }); + + it('does not report a group change the server refused', async () => { + useContactStore.setState({ contacts: [card({ id: 'g1', kind: 'group', members: {} })] }); + const client = makeClient({ deleteContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const changes = vi.fn(); + contactHooks.onContactGroupChange.register('test', changes); + + await expect(useContactStore.getState().deleteGroup(client, 'g1')).rejects.toThrow(); + await flush(); + expect(changes).not.toHaveBeenCalled(); + }); + + it('reports the members that were really added or removed, once per action', async () => { + useContactStore.setState({ + contacts: [ + card({ id: 'a', uid: 'uid-a' }), card({ id: 'b', uid: 'uid-b' }), + card({ id: 'g1', kind: 'group', members: { 'uid-a': true } }), + ], + }); + const client = makeClient(); + const members = vi.fn(); + contactHooks.onContactGroupMemberChange.register('test', members); + + // a is already in the group; only b is new. bulkAddToGroup goes through + // addMembersToGroup and must not fire twice. + await useContactStore.getState().bulkAddToGroup(client, 'g1', ['a', 'b']); + await useContactStore.getState().removeMembersFromGroup(client, 'g1', ['a']); + await flush(); + + expect(members.mock.calls.map(c => c[0])).toEqual([ + { groupId: 'g1', added: ['b'], removed: [] }, + { groupId: 'g1', added: [], removed: ['a'] }, + ]); + }); + + it('does not report members the server refused', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' }), card({ id: 'g1', kind: 'group', members: {} })] }); + const client = makeClient({ updateContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const members = vi.fn(); + contactHooks.onContactGroupMemberChange.register('test', members); + + await expect(useContactStore.getState().addMembersToGroup(client, 'g1', ['a'])).rejects.toThrow(); + await flush(); + expect(members).not.toHaveBeenCalled(); + }); + + it('reports an import with the book and the count', async () => { + let n = 0; + const client = makeClient({ + createContact: vi.fn(async (c: Partial) => ({ ...c, id: `srv-${++n}`, addressBookIds: { 'book-9': true } } as ContactCard)), + }); + const imported = vi.fn(); + contactHooks.onContactsImport.register('test', imported); + + await useContactStore.getState().importContacts(client, [card({ id: 'x' }), card({ id: 'y' })]); + await flush(); + + expect(imported).toHaveBeenCalledOnce(); + expect(imported).toHaveBeenCalledWith({ addressBookId: 'book-9', imported: 2 }); + }); + + it('does not report an import where nothing arrived', async () => { + const client = makeClient({ createContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const imported = vi.fn(); + contactHooks.onContactsImport.register('test', imported); + + await useContactStore.getState().importContacts(client, [card({ id: 'x' })]); + await flush(); + expect(imported).not.toHaveBeenCalled(); + }); + + it('reports each moved contact with the target book', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' }), card({ id: 'b' })] }); + const client = makeClient({ getContactsAccountId: () => 'acc' }); + const moved = vi.fn(); + contactHooks.onContactMove.register('test', moved); + + await useContactStore.getState().moveContactToAddressBook(client, ['a', 'b'], { id: 'book-2', name: 'Work' } as never); + await flush(); + + expect(moved.mock.calls.map(c => c[0])).toEqual([ + { contactId: 'a', toAddressBookId: 'book-2' }, + { contactId: 'b', toAddressBookId: 'book-2' }, + ]); + }); + + it('does not report a move the server refused', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' })] }); + const client = makeClient({ getContactsAccountId: () => 'acc', updateContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const moved = vi.fn(); + contactHooks.onContactMove.register('test', moved); + + await expect(useContactStore.getState().moveContactToAddressBook(client, ['a'], { id: 'book-2', name: 'Work' } as never)).rejects.toThrow(); + await flush(); + expect(moved).not.toHaveBeenCalled(); + }); + + it('reports selection changes, and only real ones', async () => { + const selection = vi.fn(); + contactHooks.onContactSelectionChange.register('test', selection); + const store = useContactStore.getState(); + + store.toggleContactSelection('a'); + store.selectRangeContacts('c', ['a', 'b', 'c']); + store.selectAllContacts(['a', 'b', 'c']); // same ids: no change + store.clearSelection(); + store.clearSelection(); // already empty: no change + await flush(); + + expect(selection.mock.calls.map(c => c[0])).toEqual([ + { contactIds: ['a'] }, + { contactIds: ['a', 'b', 'c'] }, + { contactIds: [] }, + ]); + }); +}); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 985752935..1e6aeecaa 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -14,6 +14,12 @@ function draftContactView(contact: Partial) { return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard); } +function sameIdSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const id of a) if (!b.has(id)) return false; + return true; +} + /** One compose-autocomplete suggestion: a person, or a contact group. */ type RecipientSuggestion = { name: string; email: string; group?: { id: string; memberCount: number } }; @@ -831,9 +837,11 @@ export const useContactStore = create()( members, }; + let groupId: string; if (client && get().supportsSync) { const created = await client.createContact(groupData); set((state) => ({ contacts: [...state.contacts, created] })); + groupId = created.id; } else { const localGroup: ContactCard = { id: `local-${generateUUID()}`, @@ -841,7 +849,9 @@ export const useContactStore = create()( ...groupData, } as ContactCard; set((state) => ({ contacts: [...state.contacts, localGroup] })); + groupId = localGroup.id; } + void contactHooks.onContactGroupChange.emit({ groupId, change: 'created' }); }, updateGroup: async (client, groupId, name) => { @@ -859,6 +869,7 @@ export const useContactStore = create()( c.id === groupId ? { ...c, ...updates } : c ), })); + void contactHooks.onContactGroupChange.emit({ groupId, change: 'updated' }); }, addMembersToGroup: async (client, groupId, memberIds) => { @@ -867,9 +878,11 @@ export const useContactStore = create()( if (!group) return; const newMembers = { ...group.members }; + const added: string[] = []; memberIds.forEach(id => { const contact = contacts.find(c => c.id === id); const key = contact?.uid || contact?.originalId || id; + if (!newMembers[key]) added.push(id); newMembers[key] = true; }); @@ -884,6 +897,9 @@ export const useContactStore = create()( c.id === groupId ? { ...c, members: newMembers } : c ), })); + if (added.length > 0) { + void contactHooks.onContactGroupMemberChange.emit({ groupId, added, removed: [] }); + } }, removeMembersFromGroup: async (client, groupId, memberIds) => { @@ -892,16 +908,19 @@ export const useContactStore = create()( if (!group?.members) return; const newMembers = { ...group.members }; + const removed: string[] = []; memberIds.forEach(id => { // Try direct id match first if (newMembers[id] !== undefined) { delete newMembers[id]; + removed.push(id); return; } // Try uid-based match const contact = contacts.find(c => c.id === id); if (contact?.uid && newMembers[contact.uid] !== undefined) { delete newMembers[contact.uid]; + removed.push(id); } else { // Try stripping urn:uuid: prefix matching for (const key of Object.keys(newMembers)) { @@ -909,6 +928,7 @@ export const useContactStore = create()( const bareUid = contact?.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact?.uid; if (bareKey === id || bareKey === bareUid) { delete newMembers[key]; + removed.push(id); break; } } @@ -926,6 +946,9 @@ export const useContactStore = create()( c.id === groupId ? { ...c, members: newMembers } : c ), })); + if (removed.length > 0) { + void contactHooks.onContactGroupMemberChange.emit({ groupId, added: [], removed }); + } }, deleteGroup: async (client, groupId) => { @@ -939,6 +962,7 @@ export const useContactStore = create()( contacts: state.contacts.filter(c => c.id !== groupId), selectedContactId: state.selectedContactId === groupId ? null : state.selectedContactId, })); + void contactHooks.onContactGroupChange.emit({ groupId, change: 'deleted' }); }, toggleContactSelection: (id) => set((state) => { @@ -1038,6 +1062,7 @@ export const useContactStore = create()( c.id === id ? { ...c, addressBookIds: { [addressBook.id]: true } } : c ), })); + void contactHooks.onContactMove.emit({ contactId: id, toAddressBookId: addressBook.id }); } else { // Cross-account: create in target, delete from source const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, id: _id, ...contactData } = contact; @@ -1064,6 +1089,7 @@ export const useContactStore = create()( }; }), })); + void contactHooks.onContactMove.emit({ contactId: id, toAddressBookId: addressBook.id }); } } }, @@ -1347,26 +1373,30 @@ export const useContactStore = create()( importContacts: async (client, contacts) => { const { supportsSync } = get(); let imported = 0; + // The book the cards landed in, as the server filed the first one. + let addressBookId = ''; for (const contact of contacts) { try { + let added: ContactCard; if (client && supportsSync) { const { id: _id, ...data } = contact; - const created = await client.createContact(data); - set((state) => ({ contacts: [...state.contacts, created] })); + added = await client.createContact(data); } else { - const localContact: ContactCard = { + added = { ...contact, id: `local-${generateUUID()}`, }; - set((state) => ({ contacts: [...state.contacts, localContact] })); } + set((state) => ({ contacts: [...state.contacts, added] })); + if (!addressBookId) addressBookId = Object.keys(added.addressBookIds || {}).find(k => added.addressBookIds[k]) || ''; imported++; } catch (error) { console.error('Failed to import contact:', error); } } + if (imported > 0) void contactHooks.onContactsImport.emit({ addressBookId, imported }); return imported; }, }); @@ -1381,4 +1411,15 @@ export const useContactStore = create()( ) ); +// Tell plugins when the set of checked contacts changes, whichever action +// changed it (toggle, range, select all, clear, bulk actions). Re-setting the +// same ids is not a change. +useContactStore.subscribe((state, prev) => { + if (state.selectedContactIds === prev.selectedContactIds) return; + if (contactHooks.onContactSelectionChange.size === 0) return; + const next = state.selectedContactIds ?? new Set(); + if (sameIdSet(next, prev.selectedContactIds ?? new Set())) return; + void contactHooks.onContactSelectionChange.emit({ contactIds: [...next] }); +}); + export type { ContactName }; From c02aa4f3ed4ba1ec5c1a23e88cfadacecdcdfca5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:30:00 +0200 Subject: [PATCH 041/147] feat: show plugin badges next to the subject in message-list rows --- .../__tests__/plugin-row-badges.test.tsx | 159 +++++++++++++++++ components/email/plugin-row-badges.tsx | 161 ++++++++++++++++++ components/email/thread-list-item.tsx | 5 + 3 files changed, 325 insertions(+) create mode 100644 components/email/__tests__/plugin-row-badges.test.tsx create mode 100644 components/email/plugin-row-badges.tsx diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx new file mode 100644 index 000000000..f4406bac2 --- /dev/null +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { clearRowBadgeCache, normalizeBadges, MAX_ROW_BADGES } from '../plugin-row-badges'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { Email } from '@/lib/jmap/types'; + +// onEmailListItemRender: plugins add small badges next to a row's subject. + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { + useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector: any) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + hasAttachment: false, + ...overrides, +}); + +function row(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + return ( + {}} onEmailSelect={() => {}} /> + ); +} + +beforeEach(() => { + clearAllHooks(); + clearRowBadgeCache(); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + interfaceLayout: 'default', + }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); +}); + +describe('plugin badges in message-list rows', () => { + it('shows the badges a plugin returns, with the email as a plain summary', async () => { + const handler = vi.fn(async (badges: unknown[]) => [ + ...badges, + { key: 'enc', label: 'Encrypted', color: '#2563eb', title: 'Signed and encrypted' }, + ]); + renderHooks.onEmailListItemRender.register('test', handler); + + render(row([makeEmail()])); + + const badge = await screen.findByText('Encrypted'); + expect(badge).toHaveAttribute('title', 'Signed and encrypted'); + expect(badge.style.borderColor).not.toBe(''); + expect(handler).toHaveBeenCalledTimes(1); + const [initial, ctx] = handler.mock.calls[0] as unknown as [unknown, { email: Record }]; + expect(initial).toEqual([]); + expect(ctx.email).toMatchObject({ id: 'email-1', subject: 'Test Subject', keywords: ['$seen'] }); + // A projection, not the store object. + expect(ctx.email).not.toHaveProperty('mailboxIds.inbox'); + }); + + it('shows badges on a conversation row and on the phone layout', async () => { + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]); + + render(row([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' }), + ])); + + expect(await screen.findByText('Phone badge')).toBeInTheDocument(); + }); + + it('renders the label as text, never as HTML', async () => { + renderHooks.onEmailListItemRender.register('test', async () => [ + { key: 'x', label: '' }, + ]); + + const { container } = render(row([makeEmail()])); + + expect(await screen.findByText('')).toBeInTheDocument(); + expect(container.querySelector('img[src="x"]')).toBeNull(); + }); + + it('asks plugins once per message and keyword state, not on every render', async () => { + const handler = vi.fn(async () => [{ key: 'k', label: 'Badge' }]); + renderHooks.onEmailListItemRender.register('test', handler); + const email = makeEmail(); + + const view = render(row([email])); + await screen.findByText('Badge'); + view.rerender(row([{ ...email }])); + view.unmount(); + render(row([{ ...email }])); + await screen.findByText('Badge'); + expect(handler).toHaveBeenCalledTimes(1); + + // Marking it unread is a new keyword state: ask again. + render(row([{ ...email, keywords: {} }])); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }); + + it('does nothing without a handler', async () => { + const transform = vi.spyOn(renderHooks.onEmailListItemRender, 'transform'); + render(row([makeEmail()])); + await act(async () => {}); + expect(transform).not.toHaveBeenCalled(); + expect(screen.queryByTestId('plugin-row-badges')).toBeNull(); + transform.mockRestore(); + }); +}); + +describe('normalizeBadges', () => { + it(`keeps at most ${MAX_ROW_BADGES} well-formed badges and drops unsafe colours`, () => { + const out = normalizeBadges([ + { key: 'a', label: 'A', color: 'url(https://evil.example/x.png)' }, + { key: 'b', label: '' }, + 'nonsense', + { key: 'c', label: 'C', color: 'var(--color-warning)' }, + { key: 'd', label: 'D', color: 'red; background: url(x)' }, + { key: 'e', label: 'E' }, + { key: 'f', label: 'F' }, + ]); + expect(out).toEqual([ + { key: 'a', label: 'A' }, + { key: 'c', label: 'C', color: 'var(--color-warning)' }, + { key: 'd', label: 'D' }, + ]); + expect(normalizeBadges(undefined)).toEqual([]); + }); +}); diff --git a/components/email/plugin-row-badges.tsx b/components/email/plugin-row-badges.tsx new file mode 100644 index 000000000..03e28b3f2 --- /dev/null +++ b/components/email/plugin-row-badges.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Email } from "@/lib/jmap/types"; +import type { EmailListBadge } from "@/lib/plugin-types"; +import { renderHooks } from "@/lib/plugin-hooks"; +import { emailSummary } from "@/lib/plugin-projection"; +import { cn } from "@/lib/utils"; +import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; + +// Badges plugins add to a message-list row through onEmailListItemRender. +// +// The hook is async and would otherwise run for every row on every render, so +// the answer is kept per message and keyword state: scrolling a row back into +// view, or re-rendering the list, reuses it. A keyword change (read, flagged, +// a tag) asks again, since that is what most badges are about. + +/** At most this many plugin badges per row; the row has little room. */ +export const MAX_ROW_BADGES = 3; +const MAX_LABEL_LENGTH = 32; +const MAX_TITLE_LENGTH = 200; +const CACHE_LIMIT = 2000; + +const cache = new Map(); +const inflight = new Map>(); + +function cacheKey(email: Email, listeners: number): string { + const keywords = Object.keys(email.keywords ?? {}) + .filter((k) => email.keywords?.[k]) + .sort() + .join(","); + // The listener count is part of the key so a plugin being switched on or + // off doesn't leave the old answer behind. + return `${listeners}|${email.id}|${keywords}`; +} + +// A colour the badge may use: hex, rgb()/hsl() with plain numbers, a CSS +// variable or a colour name. Anything else (url(), expressions, several +// declarations) is dropped and the badge keeps the theme colours. +const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/+-]+\)|var\(--[a-z0-9-]+\)|[a-z]{3,30})$/i; + +// eslint-disable-next-line no-control-regex -- removing control characters is the point +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g; + +function cleanText(value: unknown, max: number): string { + return typeof value === "string" ? value.replace(CONTROL_CHARS, "").trim().slice(0, max) : ""; +} + +/** Keep only well-formed badges, as plain text, capped at MAX_ROW_BADGES. */ +export function normalizeBadges(raw: unknown): EmailListBadge[] { + if (!Array.isArray(raw)) return []; + const out: EmailListBadge[] = []; + const seen = new Set(); + for (const item of raw) { + if (out.length >= MAX_ROW_BADGES) break; + if (!item || typeof item !== "object") continue; + const candidate = item as Record; + const label = cleanText(candidate.label, MAX_LABEL_LENGTH); + if (!label) continue; + const key = cleanText(candidate.key, 100) || label; + if (seen.has(key)) continue; + seen.add(key); + const badge: EmailListBadge = { key, label }; + const color = cleanText(candidate.color, 60); + if (color && SAFE_COLOR.test(color)) badge.color = color; + const title = cleanText(candidate.title, MAX_TITLE_LENGTH); + if (title) badge.title = title; + out.push(badge); + } + return out; +} + +function remember(key: string, badges: EmailListBadge[]): void { + if (cache.size >= CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, badges); +} + +function resolveBadges(email: Email, key: string): Promise { + const pending = inflight.get(key); + if (pending) return pending; + const promise = renderHooks.onEmailListItemRender.transform([], { email: emailSummary(email) }) + .then(normalizeBadges, () => []) + .then((badges) => { + remember(key, badges); + return badges; + }) + .finally(() => inflight.delete(key)); + inflight.set(key, promise); + return promise; +} + +/** Forget every cached answer (tests). */ +export function clearRowBadgeCache(): void { + cache.clear(); + inflight.clear(); +} + +/** + * The plugin badges for a row: none at first, then whatever the plugins + * returned. Without any onEmailListItemRender handler this does nothing. + */ +export function usePluginRowBadges(email: Email): EmailListBadge[] { + const listeners = renderHooks.onEmailListItemRender.size; + const key = listeners > 0 ? cacheKey(email, listeners) : null; + const [state, setState] = useState<{ key: string | null; badges: EmailListBadge[] }>(() => ({ + key, + badges: key ? cache.get(key) ?? [] : [], + })); + + useEffect(() => { + if (!key) return; + const cached = cache.get(key); + if (cached) { + setState((prev) => (prev.key === key && prev.badges === cached ? prev : { key, badges: cached })); + return; + } + let cancelled = false; + void resolveBadges(email, key).then((badges) => { + if (!cancelled) setState({ key, badges }); + }); + return () => { cancelled = true; }; + // `key` covers the parts of the email the answer depends on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + if (!key) return []; + // While a new answer is on its way, show the cached one for this key, if + // any, rather than the previous key's badges. + return state.key === key ? state.badges : cache.get(key) ?? []; +} + +/** Plugin badges next to a row's subject. Renders nothing when there are none. */ +export function PluginRowBadges({ email, className }: { email: Email; className?: string }) { + const badges = usePluginRowBadges(email); + if (badges.length === 0) return null; + return ( + + {badges.map((badge) => ( + + {badge.label} + + ))} + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f40a91e07..48697c5b2 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -29,6 +29,7 @@ import type { SwipeAction } from "@/stores/settings-store"; import { ThreadEmailItem } from "./thread-email-item"; import { EmailHoverActions } from "./email-hover-actions"; import { SearchSnippetText } from "./search-snippet-text"; +import { PluginRowBadges } from "./plugin-row-badges"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { useTranslations } from "next-intl"; @@ -596,6 +597,7 @@ const SingleEmailItem = React.forwardRef( )}> {subjectSnippet ? : (email.subject || '(no subject)')} + {(previewSnippet || inlinePreview) && ( {/* Gmail runs the snippet on from the subject after a dash. */} @@ -738,6 +740,7 @@ const SingleEmailItem = React.forwardRef( )}> {subjectSnippet ? : (email.subject || "(no subject)")} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -1182,6 +1185,7 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || '(no subject)')} + {(previewSnippet || inlinePreview) && ( {mvRow && '\u00a0-\u00a0'} @@ -1330,6 +1334,7 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || "(no subject)")} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( From e62553642fb8849061a4e16dd87d23a0105c34bd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:32:58 +0200 Subject: [PATCH 042/147] feat: tell plugins about sign-in, token renewal and account changes --- lib/__tests__/plugin-hook-catalog.test.ts | 2 +- .../__tests__/auth-store-plugin-hooks.test.ts | 72 +++++++++++++++++++ stores/account-store.ts | 4 ++ stores/auth-store.ts | 22 ++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 stores/__tests__/auth-store-plugin-hooks.test.ts diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index b3ce7ee0a..5d5dc3173 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'auth', 'security', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'security', 'ui', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/stores/__tests__/auth-store-plugin-hooks.test.ts b/stores/__tests__/auth-store-plugin-hooks.test.ts new file mode 100644 index 000000000..6c8072ae0 --- /dev/null +++ b/stores/__tests__/auth-store-plugin-hooks.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { authHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +function listen(bus: { register: (id: string, fn: (...a: unknown[]) => unknown) => unknown }) { + const fn = vi.fn(); + bus.register('test', fn); + return fn; +} + +const ACCOUNT = { + label: 'Alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org', authMode: 'basic' as const, + rememberMe: false, displayName: 'Alice', email: 'alice@example.org', lastLoginAt: 0, + isConnected: true, hasError: false, isDefault: true, +}; + +beforeEach(() => { + vi.restoreAllMocks(); + clearAllHooks(); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null, username: null }); +}); + +describe('auth hooks', () => { + it('reports accounts that are added and removed, once each', () => { + const added = listen(authHooks.onAccountAdd); + const removed = listen(authHooks.onAccountRemove); + + const id = useAccountStore.getState().addAccount(ACCOUNT); + useAccountStore.getState().addAccount(ACCOUNT); // already there: an update + useAccountStore.getState().removeAccount(id); + useAccountStore.getState().removeAccount(id); // already gone + + expect(added.mock.calls).toEqual([[{ accountId: id }]]); + expect(removed.mock.calls).toEqual([[{ accountId: id }]]); + }); + + it('says when the session is ready, and when another account becomes active', () => { + const ready = listen(authHooks.onAuthReady); + const switched = listen(authHooks.onAccountSwitch); + const client = {} as never; + + useAuthStore.setState({ isAuthenticated: true, client, activeAccountId: 'a' }); + useAuthStore.setState({ activeAccountId: 'b' }); + + expect(ready.mock.calls).toEqual([[{ accountId: 'a' }]]); + expect(switched).toHaveBeenCalledWith({ accountId: 'b', previousAccountId: 'a' }); + }); + + it('reports a renewed access token', async () => { + const refreshed = listen(authHooks.onTokenRefresh); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, status: 200, json: async () => ({ access_token: 'new', expires_in: 3600 }), + }))); + useAuthStore.setState({ activeAccountId: 'acc', authMode: 'oauth' }); + + await expect(useAuthStore.getState().refreshAccessToken()).resolves.toBe('new'); + + expect(refreshed).toHaveBeenCalledWith({ accountId: 'acc' }); + }); + + it('reports a sign-in', async () => { + const signedIn = listen(authHooks.onLogin); + + const ok = await useAuthStore.getState().loginDemo(); + + expect(ok).toBe(true); + const { activeAccountId, username } = useAuthStore.getState(); + expect(signedIn).toHaveBeenCalledWith({ accountId: activeAccountId, username }); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index d43fe42c3..2a6a37059 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; +import { authHooks } from '@/lib/plugin-hooks'; export interface AccountEntry { /** Unique key: `${username}@${serverHostname}` */ @@ -130,11 +131,13 @@ export const useAccountStore = create()( activeAccountId: s.activeAccountId ?? id, defaultAccountId: isDefault ? id : s.defaultAccountId, })); + void authHooks.onAccountAdd.emit({ accountId: id }); return id; }, removeAccount: (accountId) => { + if (!get().accounts.some((a) => a.id === accountId)) return; set((s) => { const remaining = s.accounts.filter((a) => a.id !== accountId); const wasDefault = s.defaultAccountId === accountId; @@ -158,6 +161,7 @@ export const useAccountStore = create()( defaultAccountId: newDefault, }; }); + void authHooks.onAccountRemove.emit({ accountId }); }, setActiveAccount: (accountId) => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 389a05234..6961a0064 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1250,6 +1250,11 @@ function performFullLogout(set: (state: Partial) => void): void { */ export const LOGOUT_HOOK_BUDGET_MS = 2000; +function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void { + if (!state.activeAccountId) return; + void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' }); +} + function beforeLogoutHooks(accountId: string): Promise { return Promise.race([ authHooks.onBeforeLogout.emit({ accountId }), @@ -1508,6 +1513,7 @@ export const useAuthStore = create()( }); }).catch(() => {}); + notifySignedIn(get()); return true; } catch (error) { debug.error('Login error:', error); @@ -1622,6 +1628,7 @@ export const useAuthStore = create()( }).catch(() => {}); resumeSettingsSync({ id: accountId, username, serverUrl }); + notifySignedIn(get()); return true; } catch (error) { debug.error('Token login error:', error); @@ -1687,6 +1694,7 @@ export const useAuthStore = create()( activeAccountId: demoAccountId, isDemoMode: true, }); + notifySignedIn(get()); return true; } catch (error) { debug.error('Demo login error:', error); @@ -1830,6 +1838,7 @@ export const useAuthStore = create()( sessionStorage.removeItem('oauth_cookie_slot'); } + notifySignedIn(get()); return true; } catch (error) { debug.error('OAuth login error:', error); @@ -1967,6 +1976,7 @@ export const useAuthStore = create()( }); }).catch(() => {}); + notifySignedIn(get()); return true; } catch (error) { debug.error('Server SSO login error:', error); @@ -2061,6 +2071,7 @@ export const useAuthStore = create()( resetRefreshBackoff(accountId ?? undefined); scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined); + if (accountId) void authHooks.onTokenRefresh.emit({ accountId }); return access_token; } catch (error) { // Network failure (offline, Wi-Fi switch, server unreachable) - @@ -3059,3 +3070,14 @@ if (typeof window !== 'undefined') { if (useAuthStore.getState().isAuthenticated) navigateToLogin(); }); } + +// Plugins hear when a session becomes usable (after start-up or a sign-in) +// and when another account becomes active, whichever path got there. +useAuthStore.subscribe((state, prev) => { + const ready = state.isAuthenticated && !!state.client && !!state.activeAccountId; + const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId; + if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId }); + if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) { + void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId }); + } +}); From 96f0681ab5026ee98f32fa1c6ff549c17a60c378 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:36:13 +0200 Subject: [PATCH 043/147] feat: tell plugins when files are uploaded, downloaded, deleted, renamed, moved or copied, and let them stop it --- components/files/files-app.tsx | 14 +- stores/__tests__/file-store-hooks.test.ts | 387 ++++++++++++++++++++++ stores/file-store.ts | 324 +++++++++++++----- 3 files changed, 641 insertions(+), 84 deletions(-) create mode 100644 stores/__tests__/file-store-hooks.test.ts diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index f1d7ca70f..8133f2d1d 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -357,7 +357,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { if (!confirmed) return; try { - await deleteResource(name); + if (!await deleteResource(name)) return; toast.success(t("delete_success")); } catch (err) { console.error("Failed to delete:", err); @@ -375,7 +375,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { if (!confirmed) return; try { - await deleteResources(names); + if (!await deleteResources(names)) return; toast.success(t("batch_delete_success", { count: names.length })); } catch (err) { console.error("Failed to batch delete:", err); @@ -395,7 +395,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handleRename = useCallback(async (oldName: string, newName: string) => { try { - await renameResource(oldName, newName); + if (!await renameResource(oldName, newName)) return; toast.success(t("rename_success"), { action: { label: t("undo"), onClick: handleUndo }, }); @@ -451,7 +451,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handleMoveTo = useCallback(async (names: string[], targetId: string | null) => { try { - await moveToFolderId(names, targetId); + if (!await moveToFolderId(names, targetId)) return; toast.success(t("move_success", { count: names.length }), { action: { label: t("undo"), onClick: handleUndo }, }); @@ -463,7 +463,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handlePaste = useCallback(async () => { try { - await pasteResources(); + if (!await pasteResources()) return; toast.success(t("paste_success"), { action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined, }); @@ -485,7 +485,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handleRenameNode = useCallback(async (id: string, newName: string) => { try { - await renameNode(id, newName); + if (!await renameNode(id, newName)) return; toast.success(t("rename_success"), { action: { label: t("undo"), onClick: handleUndo }, }); @@ -504,7 +504,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }); if (!confirmed) return; try { - await deleteNode(id); + if (!await deleteNode(id)) return; toast.success(t("delete_success")); } catch (err) { console.error("Failed to delete:", err); diff --git a/stores/__tests__/file-store-hooks.test.ts b/stores/__tests__/file-store-hooks.test.ts new file mode 100644 index 000000000..39798efaa --- /dev/null +++ b/stores/__tests__/file-store-hooks.test.ts @@ -0,0 +1,387 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useFileStore } from '../file-store'; +import { fileHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; + +const dir = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'd', blobId: null, size: 0, created: '', modified: '2026-01-01T00:00:00Z', +}); +const file = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', modified: '2026-01-01T00:00:00Z', +}); + +/** In-memory FileNode backend with spies on every server call. */ +function makeClient(initial: FileNode[]) { + let nodes = initial.map(n => ({ ...n })); + let seq = 0; + const create = (name: string, blobId: string | null, type: string, size: number, parentId: string | null) => { + const node: FileNode = { id: `new${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }; + const client = { + listAllFileNodes: vi.fn(async () => nodes.map(n => ({ ...n }))), + createFileDirectory: vi.fn(async (name: string, parentId: string | null) => create(name, null, 'd', 0, parentId)), + createFileNode: vi.fn(async (name: string, blobId: string, type: string, size: number, parentId: string | null) => + create(name, blobId, type, size, parentId)), + updateFileNode: vi.fn(async (id: string, patch: Partial) => { + const node = nodes.find(n => n.id === id); + if (node) Object.assign(node, patch); + }), + destroyFileNodes: vi.fn(async (ids: string[]) => { + nodes = nodes.filter(n => !ids.includes(n.id)); + return { destroyed: ids, notDestroyed: [] }; + }), + copyFileNode: vi.fn(async (id: string, newName: string, parentId: string | null) => { + const original = nodes.find(n => n.id === id)!; + return create(newName, original.blobId, original.type, original.size, parentId); + }), + uploadBlob: vi.fn(async (f: File) => ({ blobId: `blob-${f.name}`, type: f.type })), + downloadBlob: vi.fn(async () => undefined), + }; + return client as unknown as IJMAPClient & typeof client; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** A drive with /Docs (a.txt, b.txt) and /Docs/Target, opened at /Docs. */ +async function openDocs() { + const client = makeClient([ + dir('docs', 'Docs', null), + file('a', 'a.txt', 'docs'), + file('b', 'b.txt', 'docs'), + dir('target', 'Target', 'docs'), + ]); + useFileStore.setState({ + client, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], + resources: [], allFolders: [], selectedResources: new Set(), clipboard: null, lastAction: null, + uploadAbortController: null, uploadProgress: null, hasListed: false, + }); + await useFileStore.getState().navigate('docs', 'Docs'); + return client; +} + +const names = () => useFileStore.getState().resources.map(r => r.name).sort(); + +describe('file-store plugin hooks: changing files', () => { + beforeEach(() => clearAllHooks()); + + describe('uploads', () => { + it('asks about each file before it goes up and skips the refused ones', async () => { + const client = await openDocs(); + const before = vi.fn(({ name }: { name: string }) => { + expect(client.uploadBlob).not.toHaveBeenCalled(); + return name !== 'no.txt'; + }); + const after = vi.fn(); + fileHooks.onBeforeFileUpload.register('test', before); + fileHooks.onAfterFileUpload.register('test', after); + + const yes = new File(['hello'], 'yes.txt', { type: 'text/plain' }); + const no = new File(['nope'], 'no.txt', { type: 'text/plain' }); + const result = await useFileStore.getState().uploadFiles([yes, no]); + await flush(); + + expect(before).toHaveBeenCalledTimes(2); + expect(before).toHaveBeenCalledWith({ name: 'yes.txt', size: 5, type: 'text/plain', path: '/Docs' }); + expect(result.uploaded).toBe(1); + expect(client.uploadBlob).toHaveBeenCalledTimes(1); + expect(names()).not.toContain('no.txt'); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ name: 'yes.txt', type: 'file', mimeType: 'text/plain', path: '/Docs' }); + }); + + it('uploads nothing when every file is refused', async () => { + const client = await openDocs(); + fileHooks.onBeforeFileUpload.register('test', () => false); + + await useFileStore.getState().uploadFile(new File(['x'], 'x.txt')); + const result = await useFileStore.getState().uploadFiles([new File(['y'], 'y.txt')]); + + expect(result).toEqual({ uploaded: 0, canceled: false }); + expect(client.uploadBlob).not.toHaveBeenCalled(); + expect(client.createFileNode).not.toHaveBeenCalled(); + }); + + it('does not create the folder of a refused file in a folder upload', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileUpload.register('test', ({ path }: { path: string }) => path !== '/Docs/secret'); + fileHooks.onAfterFileUpload.register('test', after); + + const inSub = new File(['s'], 'key.pem'); + Object.defineProperty(inSub, 'webkitRelativePath', { value: 'secret/key.pem' }); + const top = new File(['t'], 'readme.md'); + await useFileStore.getState().uploadFolder([inSub, top]); + await flush(); + + expect(client.createFileDirectory).not.toHaveBeenCalled(); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ name: 'readme.md', path: '/Docs' }); + }); + + it('does not report an upload the server refused', async () => { + const client = await openDocs(); + client.createFileNode.mockRejectedValueOnce(new Error('quota')); + const after = vi.fn(); + fileHooks.onAfterFileUpload.register('test', after); + + await expect(useFileStore.getState().uploadFiles([new File(['x'], 'x.txt')])).rejects.toThrow('quota'); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a cancelled upload, but only while one runs', async () => { + await openDocs(); + const cancelled = vi.fn(); + fileHooks.onFileUploadCancel.register('test', cancelled); + + useFileStore.getState().cancelUpload(); + useFileStore.setState({ + uploadAbortController: new AbortController(), + uploadProgress: { name: 'big.iso', loaded: 1, total: 9, current: 1, totalFiles: 1 }, + }); + useFileStore.getState().cancelUpload(); + await flush(); + + expect(cancelled).toHaveBeenCalledOnce(); + expect(cancelled).toHaveBeenCalledWith({ name: 'big.iso', path: '/Docs' }); + }); + }); + + it('reports a created folder', async () => { + await openDocs(); + const created = vi.fn(); + fileHooks.onDirectoryCreate.register('test', created); + + await useFileStore.getState().createDirectory('New'); + await flush(); + + expect(created).toHaveBeenCalledOnce(); + expect(created.mock.calls[0][0]).toMatchObject({ name: 'New', type: 'directory', path: '/Docs' }); + }); + + describe('delete', () => { + it('asks once for all files and reports them', async () => { + const client = await openDocs(); + const before = vi.fn(() => { + expect(client.destroyFileNodes).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + fileHooks.onBeforeFileDelete.register('test', before); + fileHooks.onAfterFileDelete.register('test', after); + + expect(await useFileStore.getState().deleteResources(['a.txt', 'b.txt'])).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + const asked = (before.mock.calls[0] as unknown as [{ files: { id: string; path: string }[] }])[0]; + expect(asked.files.map(f => f.id)).toEqual(['a', 'b']); + expect(asked.files[0].path).toBe('/Docs'); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0].files.map((f: { id: string }) => f.id)).toEqual(['a', 'b']); + }); + + it('keeps everything when a plugin says no', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileDelete.register('test', () => false); + fileHooks.onAfterFileDelete.register('test', after); + + expect(await useFileStore.getState().deleteResource('a.txt')).toBe(false); + expect(await useFileStore.getState().deleteResources(['a.txt', 'b.txt'])).toBe(false); + expect(await useFileStore.getState().deleteNode('target')).toBe(false); + await flush(); + + expect(client.destroyFileNodes).not.toHaveBeenCalled(); + expect(names()).toEqual(['Target', 'a.txt', 'b.txt']); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a delete the server refused', async () => { + const client = await openDocs(); + client.destroyFileNodes.mockRejectedValueOnce(new Error('locked')); + const after = vi.fn(); + fileHooks.onAfterFileDelete.register('test', after); + + await expect(useFileStore.getState().deleteResource('a.txt')).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a folder deleted from the tree with its path', async () => { + await openDocs(); + const after = vi.fn(); + fileHooks.onAfterFileDelete.register('test', after); + + await useFileStore.getState().deleteNode('target'); + await flush(); + expect(after.mock.calls[0][0].files[0]).toMatchObject({ id: 'target', name: 'Target', type: 'directory', path: '/Docs' }); + }); + }); + + describe('rename', () => { + it('passes the file and new name first, then the previous name', async () => { + const client = await openDocs(); + const before = vi.fn(() => { + expect(client.updateFileNode).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + fileHooks.onBeforeFileRename.register('test', before); + fileHooks.onFileRename.register('test', after); + + expect(await useFileStore.getState().renameResource('a.txt', 'c.txt')).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledWith({ file: expect.objectContaining({ id: 'a', name: 'a.txt', path: '/Docs' }), newName: 'c.txt' }); + expect(after).toHaveBeenCalledWith({ file: expect.objectContaining({ id: 'a', name: 'c.txt' }), previousName: 'a.txt' }); + }); + + it('keeps the name when a plugin says no', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileRename.register('test', () => false); + fileHooks.onFileRename.register('test', after); + + expect(await useFileStore.getState().renameResource('a.txt', 'c.txt')).toBe(false); + expect(await useFileStore.getState().renameNode('target', 'Moved')).toBe(false); + await flush(); + + expect(client.updateFileNode).not.toHaveBeenCalled(); + expect(useFileStore.getState().lastAction).toBeNull(); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a rename the server refused', async () => { + const client = await openDocs(); + client.updateFileNode.mockRejectedValueOnce(new Error('taken')); + const after = vi.fn(); + fileHooks.onFileRename.register('test', after); + + await expect(useFileStore.getState().renameNode('target', 'X')).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('move and copy', () => { + it('asks once for all moved files with the target path', async () => { + const client = await openDocs(); + const before = vi.fn(() => { + expect(client.updateFileNode).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + fileHooks.onBeforeFileMove.register('test', before); + fileHooks.onFileMove.register('test', after); + + expect(await useFileStore.getState().moveToFolderId(['a.txt', 'b.txt'], 'target')).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + const asked = (before.mock.calls[0] as unknown as [{ files: { id: string }[]; toPath: string }])[0]; + expect(asked.files.map(f => f.id)).toEqual(['a', 'b']); + expect(asked.toPath).toBe('/Docs/Target'); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0].toPath).toBe('/Docs/Target'); + expect(after.mock.calls[0][0].files.map((f: { id: string }) => f.id)).toEqual(['a', 'b']); + }); + + it('moves nothing when a plugin says no', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileMove.register('test', () => false); + fileHooks.onFileMove.register('test', after); + + expect(await useFileStore.getState().moveToFolderId(['a.txt'], 'target')).toBe(false); + expect(await useFileStore.getState().moveToFolder(['a.txt'], 'Target')).toBe(false); + expect(await useFileStore.getState().moveToParent(['a.txt'])).toBe(false); + useFileStore.getState().cutResources(['b.txt']); + await useFileStore.getState().navigate('target', 'Target'); + expect(await useFileStore.getState().pasteResources()).toBe(false); + await flush(); + + expect(client.updateFileNode).not.toHaveBeenCalled(); + expect(useFileStore.getState().clipboard).not.toBeNull(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports only the files that moved before a failure', async () => { + const client = await openDocs(); + client.updateFileNode.mockImplementationOnce(async () => undefined).mockRejectedValueOnce(new Error('denied')); + const after = vi.fn(); + fileHooks.onFileMove.register('test', after); + + await expect(useFileStore.getState().moveToParent(['a.txt', 'b.txt'])).rejects.toThrow('denied'); + await flush(); + + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ toPath: '/', files: [expect.objectContaining({ id: 'a', path: '/' })] }); + }); + + it('reports cut and paste as a move and copy and paste as a copy', async () => { + await openDocs(); + const moved = vi.fn(); + const copied = vi.fn(); + fileHooks.onFileMove.register('test', moved); + fileHooks.onFileCopy.register('test', copied); + + useFileStore.getState().copyResources(['a.txt']); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().pasteResources(); + await useFileStore.getState().navigate('docs', 'Docs'); + useFileStore.getState().cutResources(['b.txt']); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().pasteResources(); + await flush(); + + expect(copied).toHaveBeenCalledOnce(); + const copy = copied.mock.calls[0][0]; + expect(copy.toPath).toBe('/Docs/Target'); + expect(copy.files[0]).toMatchObject({ name: 'a.txt', path: '/Docs/Target' }); + expect(copy.files[0].id).not.toBe('a'); + expect(moved).toHaveBeenCalledOnce(); + expect(moved.mock.calls[0][0]).toMatchObject({ toPath: '/Docs/Target', files: [expect.objectContaining({ id: 'b', path: '/Docs/Target' })] }); + }); + + it('reports a duplicate with the new copy', async () => { + await openDocs(); + const duplicated = vi.fn(); + fileHooks.onFileDuplicate.register('test', duplicated); + + await useFileStore.getState().duplicateResource('a.txt'); + await flush(); + + expect(duplicated).toHaveBeenCalledOnce(); + expect(duplicated.mock.calls[0][0]).toMatchObject({ name: 'a (copy).txt', path: '/Docs', type: 'file' }); + }); + }); + + describe('download', () => { + it('reports each downloaded file', async () => { + await openDocs(); + const downloaded = vi.fn(); + fileHooks.onFileDownload.register('test', downloaded); + + await useFileStore.getState().downloadResources(['a.txt', 'b.txt']); + await useFileStore.getState().downloadNode('a'); + await flush(); + + expect(downloaded.mock.calls.map(c => [c[0].id, c[0].path])).toEqual([['a', '/Docs'], ['b', '/Docs'], ['a', '/Docs']]); + }); + + it('does not report a failed download', async () => { + const client = await openDocs(); + client.downloadBlob.mockRejectedValueOnce(new Error('gone')); + const downloaded = vi.fn(); + fileHooks.onFileDownload.register('test', downloaded); + + await expect(useFileStore.getState().downloadResource('a.txt')).rejects.toThrow(); + await flush(); + expect(downloaded).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 5a0c56c0e..212b5e02e 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -2,6 +2,9 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; import { imageBlobUrl } from '@/lib/file-preview'; +import { fileHooks } from '@/lib/plugin-hooks'; +import { fileToView } from '@/lib/plugin-projection'; +import type { FileResourceView } from '@/lib/plugin-types'; export interface FileResource { id: string; @@ -38,6 +41,8 @@ interface ClipboardState { serverNames: string[]; sourceParentId: string | null; sourcePath: string; + /** The cut or copied items as plugins see them, taken when they were picked. */ + files: FileResourceView[]; } /** How a batch upload ended: files that made it, and whether the user cancelled. */ @@ -104,22 +109,24 @@ interface FileState { uploadFiles: (files: File[]) => Promise; uploadFolder: (files: File[]) => Promise; cancelUpload: () => void; - deleteResource: (name: string) => Promise; - deleteResources: (names: string[]) => Promise; - renameResource: (oldName: string, newName: string) => Promise; + // Deletes, renames and moves resolve to false when a plugin stopped them + // (onBeforeFileDelete / Rename / Move); nothing changed then. + deleteResource: (name: string) => Promise; + deleteResources: (names: string[]) => Promise; + renameResource: (oldName: string, newName: string) => Promise; downloadResource: (name: string) => Promise; downloadResources: (names: string[]) => Promise; getImageUrl: (name: string) => Promise; getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>; createTextFile: (name: string) => Promise; duplicateResource: (name: string) => Promise; - moveToFolder: (names: string[], targetFolder: string) => Promise; - moveToParent: (names: string[]) => Promise; + moveToFolder: (names: string[], targetFolder: string) => Promise; + moveToParent: (names: string[]) => Promise; /** Move listed items into any folder by id (null = the root), e.g. a tree or breadcrumb drop. */ - moveToFolderId: (names: string[], targetId: string | null) => Promise; + moveToFolderId: (names: string[], targetId: string | null) => Promise; cutResources: (names: string[]) => void; copyResources: (names: string[]) => void; - pasteResources: () => Promise; + pasteResources: () => Promise; selectResource: (name: string | null) => void; toggleSelect: (name: string) => void; selectAll: () => void; @@ -132,9 +139,9 @@ interface FileState { /** Open the folder holding a node and return the node's name, or null when it is gone. */ revealNode: (id: string) => Promise; /** Rename any node by id, e.g. a folder in the tree that is not listed. */ - renameNode: (id: string, newName: string) => Promise; + renameNode: (id: string, newName: string) => Promise; /** Delete any node by id; leaves the folder on screen if it was inside it. */ - deleteNode: (id: string) => Promise; + deleteNode: (id: string) => Promise; /** Download a file by id, wherever it lives. */ downloadNode: (id: string) => Promise; removeRecentFile: (id: string) => void; @@ -280,6 +287,66 @@ function buildPathFromStack(stack: { id: string | null; name: string }[]): strin return '/' + stack.slice(1).map(s => s.name).join('/'); } +function joinPath(base: string, name: string): string { + if (!name) return base; + return base === '/' || base === '' ? `/${name}` : `${base}/${name}`; +} + +/** A listed item as plugins see it; `path` is the folder that holds it. */ +function resourceToView(r: FileResource, path: string): FileResourceView { + return fileToView({ + id: r.id, + parentId: r.parentId, + name: r.name, + type: r.isDirectory ? 'd' : r.contentType, + blobId: r.isDirectory ? null : r.blobId, + size: r.contentLength, + created: r.lastModified, + modified: r.lastModified, + }, path); +} + +/** Path of the folder holding a node, from the folder list ('' when unknown). */ +function parentPath(folders: FileResource[], parentId: string | null | undefined): string { + if (parentId == null) return '/'; + return folderPath(folders, parentId) ?? ''; +} + +/** Views of the items whose ids the server reports as destroyed. */ +function destroyedViews(views: FileResourceView[], result: { destroyed?: string[] } | undefined): FileResourceView[] { + if (!result?.destroyed) return views; + const gone = new Set(result.destroyed); + return views.filter(v => gone.has(v.id)); +} + +/** A node that is not in any listing; plugins get at least its id. */ +function unknownNodeView(id: string): FileResourceView { + return { id, name: '', type: 'file', size: 0, mimeType: '', path: '', modified: '' }; +} + +/** Ask plugins once about moving these items from `fromPath` to `toPath`. */ +async function approveMove(items: FileResource[], fromPath: string, toPath: string): Promise { + if (items.length === 0) return true; + const files = items.map(r => resourceToView(r, fromPath)); + return fileHooks.onBeforeFileMove.intercept({ files, toPath }); +} + +/** + * Move each item with `moveOne`, then tell plugins which ones moved. Items + * moved before a failure are still reported; the error is passed on. + */ +async function reportMoves(items: FileResource[], toPath: string, moveOne: (r: FileResource) => Promise): Promise { + const moved: FileResourceView[] = []; + try { + for (const resource of items) { + await moveOne(resource); + moved.push(resourceToView(resource, toPath)); + } + } finally { + if (moved.length > 0) void fileHooks.onFileMove.emit({ files: moved, toPath }); + } +} + export const useFileStore = create((set, get) => ({ currentParentId: null, currentPath: '/', @@ -644,16 +711,18 @@ export const useFileStore = create((set, get) => ({ }, createDirectory: async (name: string) => { - const { client, currentParentId, refresh } = get(); + const { client, currentParentId, currentPath, refresh } = get(); if (!client) return; - await client.createFileDirectory(name, currentParentId); + const created = await client.createFileDirectory(name, currentParentId); + void fileHooks.onDirectoryCreate.emit(fileToView(created, currentPath)); await refresh(); }, uploadFile: async (file: File) => { - const { client, currentParentId } = get(); + const { client, currentParentId, currentPath } = get(); if (!client) return; + if (!(await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path: currentPath }))) return; const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -669,17 +738,27 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) return; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } }); - await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath)); } finally { set({ uploadProgress: null, uploadAbortController: null }); } }, - uploadFiles: async (files: File[]) => { - const { client, currentParentId, resources } = get(); + uploadFiles: async (allFiles: File[]) => { + const { client, currentParentId, currentPath, resources } = get(); if (!client) return { uploaded: 0, canceled: false }; let uploaded = 0; + // Plugins may hold back single files; the rest go up. + const files: File[] = []; + for (const file of allFiles) { + if (await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path: currentPath })) { + files.push(file); + } + } + if (files.length === 0) return { uploaded: 0, canceled: false }; + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -702,8 +781,9 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); - await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + const node = await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); uploaded++; + void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath)); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; set({ uploadProgress: null, uploadAbortController: null }); @@ -716,18 +796,36 @@ export const useFileStore = create((set, get) => ({ }, cancelUpload: () => { - const { uploadAbortController } = get(); + const { uploadAbortController, uploadProgress, currentPath } = get(); if (uploadAbortController) { uploadAbortController.abort(); set({ uploadProgress: null, uploadAbortController: null }); + void fileHooks.onFileUploadCancel.emit({ name: uploadProgress?.name ?? '', path: currentPath }); } }, - uploadFolder: async (files: File[]) => { - const { client, currentParentId } = get(); - if (!client || files.length === 0) return { uploaded: 0, canceled: false }; + uploadFolder: async (allFiles: File[]) => { + const { client, currentParentId, currentPath } = get(); + if (!client || allFiles.length === 0) return { uploaded: 0, canceled: false }; let uploaded = 0; + const relativeOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + const dirOf = (relativePath: string) => { + const slash = relativePath.lastIndexOf('/'); + return slash >= 0 ? relativePath.slice(0, slash) : ''; + }; + + // Ask plugins about every file before anything is created, so a folder + // that only held refused files is not created either. + const files: File[] = []; + for (const file of allFiles) { + const path = joinPath(currentPath, dirOf(relativeOf(file))); + if (await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path })) { + files.push(file); + } + } + if (files.length === 0) return { uploaded: 0, canceled: false }; + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -758,6 +856,7 @@ export const useFileStore = create((set, get) => ({ try { const created = await client.createFileDirectory(dirName, parentId); dirIds.set(dir, created.id); + void fileHooks.onDirectoryCreate.emit(fileToView(created, joinPath(currentPath, parentPath))); } catch { // Directory may already exist - leave it unmapped; files fall back to // the closest known parent below. @@ -768,9 +867,8 @@ export const useFileStore = create((set, get) => ({ for (let i = 0; i < files.length; i++) { if (abortController.signal.aborted) break; const file = files[i]; - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; - const slash = relativePath.lastIndexOf('/'); - const dirPath = slash >= 0 ? relativePath.slice(0, slash) : ''; + const relativePath = relativeOf(file); + const dirPath = dirOf(relativePath); const parentId = dirIds.get(dirPath) ?? currentParentId; set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } }); @@ -785,8 +883,11 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); - await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); + const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); uploaded++; + // A file whose folder could not be created lands in the closest known one. + const landedIn = dirIds.has(dirPath) ? joinPath(currentPath, dirPath) : currentPath; + void fileHooks.onAfterFileUpload.emit(fileToView(node, landedIn)); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; set({ uploadProgress: null, uploadAbortController: null }); @@ -799,50 +900,68 @@ export const useFileStore = create((set, get) => ({ }, deleteResource: async (name: string) => { - const { client, resources, recentFiles, refresh } = get(); - if (!client) return; + const { client, resources, recentFiles, currentPath, refresh } = get(); + if (!client) return false; const resource = resources.find(r => r.name === name); - if (!resource) return; + if (!resource) return false; + + const files = [resourceToView(resource, currentPath)]; + if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false; // The server removes descendant nodes (onDestroyRemoveChildren). - await client.destroyFileNodes([resource.id]); + const result = await client.destroyFileNodes([resource.id]); + const deleted = destroyedViews(files, result); + if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted }); const nextRecentFiles = recentFiles.filter(r => r.id !== resource.id); set({ recentFiles: nextRecentFiles }); try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ } await refresh(); + return true; }, deleteResources: async (names: string[]) => { - const { client, resources, recentFiles, refresh } = get(); - if (!client) return; + const { client, resources, recentFiles, currentPath, refresh } = get(); + if (!client) return false; const idsToDelete: string[] = []; + const files: FileResourceView[] = []; for (const name of names) { const resource = resources.find(r => r.name === name); - if (resource) idsToDelete.push(resource.id); + if (resource) { + idsToDelete.push(resource.id); + files.push(resourceToView(resource, currentPath)); + } } - if (idsToDelete.length === 0) return; + if (idsToDelete.length === 0) return false; + if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false; // The server removes descendant nodes (onDestroyRemoveChildren). - await client.destroyFileNodes(idsToDelete); + const result = await client.destroyFileNodes(idsToDelete); + const deleted = destroyedViews(files, result); + if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted }); const deletedIdSet = new Set(idsToDelete); const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id)); set({ selectedResources: new Set() }); set({ recentFiles: nextRecentFiles }); try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ } await refresh(); + return true; }, renameResource: async (oldName: string, newName: string) => { - const { client, resources, refresh } = get(); - if (!client) return; + const { client, resources, currentPath, refresh } = get(); + if (!client) return false; const resource = resources.find(r => r.name === oldName); - if (!resource) return; + if (!resource) return false; + + const file = resourceToView(resource, currentPath); + if (!(await fileHooks.onBeforeFileRename.intercept({ file, newName }))) return false; await client.updateFileNode(resource.id, { name: newName }); + void fileHooks.onFileRename.emit({ file: { ...file, name: newName }, previousName: oldName }); set({ lastAction: { @@ -852,16 +971,18 @@ export const useFileStore = create((set, get) => ({ }, }); await refresh(); + return true; }, downloadResource: async (name: string) => { - const { client, resources } = get(); + const { client, resources, currentPath } = get(); if (!client) return; const resource = resources.find(r => r.name === name); if (!resource?.blobId) return; await client.downloadBlob(resource.blobId, resource.name, resource.contentType); + void fileHooks.onFileDownload.emit(resourceToView(resource, currentPath)); }, downloadResources: async (names: string[]) => { @@ -908,7 +1029,7 @@ export const useFileStore = create((set, get) => ({ }, duplicateResource: async (name: string) => { - const { client, resources, currentParentId, refresh } = get(); + const { client, resources, currentParentId, currentPath, refresh } = get(); if (!client) return; const resource = resources.find(r => r.name === name); @@ -919,56 +1040,67 @@ export const useFileStore = create((set, get) => ({ ? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}` : `${name} (copy)`; - await client.copyFileNode(resource.id, copyName, currentParentId); + const copy = await client.copyFileNode(resource.id, copyName, currentParentId); + void fileHooks.onFileDuplicate.emit(fileToView(copy, currentPath)); await refresh(); }, moveToFolder: async (names: string[], targetFolder: string) => { - const { client, resources, refresh } = get(); - if (!client) return; + const { client, resources, currentPath, refresh } = get(); + if (!client) return false; const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory); - if (!targetResource) return; + if (!targetResource) return false; + + const toMove = names + .map(name => resources.find(r => r.name === name)) + .filter((r): r is FileResource => !!r && r.id !== targetResource.id); + const toPath = joinPath(currentPath, targetFolder); + if (!(await approveMove(toMove, currentPath, toPath))) return false; const entries: UndoAction['entries'] = []; - for (const name of names) { - const resource = resources.find(r => r.name === name); - if (!resource || resource.id === targetResource.id) continue; + await reportMoves(toMove, toPath, async (resource) => { await client.updateFileNode(resource.id, { parentId: targetResource.id }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetResource.id } }); - } + }); set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourceParentId: null }, }); await refresh(); + return true; }, moveToParent: async (names: string[]) => { - const { client, resources, pathStack, refresh } = get(); - if (!client || pathStack.length <= 1) return; + const { client, resources, pathStack, currentPath, refresh } = get(); + if (!client || pathStack.length <= 1) return false; // Move into the grandparent of the current folder's contents, i.e. the // entry one level up in the breadcrumb stack. const newParentId = pathStack[pathStack.length - 2].id; + const toMove = names + .map(name => resources.find(r => r.name === name)) + .filter((r): r is FileResource => !!r); + const toPath = buildPathFromStack(pathStack.slice(0, -1)); + if (!(await approveMove(toMove, currentPath, toPath))) return false; + const entries: UndoAction['entries'] = []; - for (const name of names) { - const resource = resources.find(r => r.name === name); - if (!resource) continue; + await reportMoves(toMove, toPath, async (resource) => { await client.updateFileNode(resource.id, { parentId: newParentId }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: newParentId } }); - } + }); set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourceParentId: null }, }); await refresh(); + return true; }, moveToFolderId: async (names: string[], targetId: string | null) => { - const { client, resources, currentParentId, allFolders, refresh } = get(); - if (!client || targetId === currentParentId) return; + const { client, resources, currentParentId, currentPath, allFolders, refresh } = get(); + if (!client || targetId === currentParentId) return false; // A folder must not move into itself or anything below it. const parentOf = new Map(allFolders.map(f => [f.id, f.parentId ?? null])); @@ -979,50 +1111,74 @@ export const useFileStore = create((set, get) => ({ return false; }; + const toMove = names + .map(name => resources.find(r => r.name === name)) + .filter((r): r is FileResource => !!r && !(r.isDirectory && isInside(targetId, r.id))); + const toPath = parentPath(allFolders, targetId); + if (!(await approveMove(toMove, currentPath, toPath))) return false; + const entries: UndoAction['entries'] = []; - for (const name of names) { - const resource = resources.find(r => r.name === name); - if (!resource) continue; - if (resource.isDirectory && isInside(targetId, resource.id)) continue; + await reportMoves(toMove, toPath, async (resource) => { await client.updateFileNode(resource.id, { parentId: targetId }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetId } }); - } + }); set({ selectedResources: new Set(), lastAction: entries.length > 0 ? { type: 'move', entries, sourceParentId: null } : get().lastAction, }); await refresh(); + return true; }, cutResources: (names: string[]) => { const { currentPath, currentParentId, resources } = get(); const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[]; const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[]; - set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } }); + const files = resources.filter(r => names.includes(r.name)).map(r => resourceToView(r, currentPath)); + set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath, files } }); }, copyResources: (names: string[]) => { const { currentPath, currentParentId, resources } = get(); const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[]; const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[]; - set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } }); + const files = resources.filter(r => names.includes(r.name)).map(r => resourceToView(r, currentPath)); + set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath, files } }); }, pasteResources: async () => { - const { client, currentParentId, clipboard, refresh } = get(); - if (!client || !clipboard) return; + const { client, currentParentId, currentPath, clipboard, refresh } = get(); + if (!client || !clipboard) return false; + + const viewOf = (id: string) => clipboard.files?.find(f => f.id === id); + if (clipboard.mode === 'cut') { + const files = clipboard.ids.map(viewOf).filter((f): f is FileResourceView => !!f); + if (!(await fileHooks.onBeforeFileMove.intercept({ files, toPath: currentPath }))) return false; + } const entries: UndoAction['entries'] = []; + const done: FileResourceView[] = []; - for (let i = 0; i < clipboard.ids.length; i++) { - const id = clipboard.ids[i]; - const displayName = clipboard.names[i]; + try { + for (let i = 0; i < clipboard.ids.length; i++) { + const id = clipboard.ids[i]; + const displayName = clipboard.names[i]; - if (clipboard.mode === 'cut') { - await client.updateFileNode(id, { parentId: currentParentId }); - entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } }); - } else { - await client.copyFileNode(id, displayName, currentParentId); + if (clipboard.mode === 'cut') { + await client.updateFileNode(id, { parentId: currentParentId }); + entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } }); + const view = viewOf(id); + if (view) done.push({ ...view, path: currentPath }); + } else { + const copy = await client.copyFileNode(id, displayName, currentParentId); + done.push(fileToView(copy, currentPath)); + } + } + } finally { + // Report what really happened, also when a later item failed. + if (done.length > 0) { + if (clipboard.mode === 'cut') void fileHooks.onFileMove.emit({ files: done, toPath: currentPath }); + else void fileHooks.onFileCopy.emit({ files: done, toPath: currentPath }); } } @@ -1033,6 +1189,7 @@ export const useFileStore = create((set, get) => ({ }); } await refresh(); + return true; }, selectResource: (name: string | null) => { @@ -1137,11 +1294,15 @@ export const useFileStore = create((set, get) => ({ }, renameNode: async (id: string, newName: string) => { - const { client, allFolders, favorites, refresh } = get(); - if (!client) return; + const { client, allFolders, resources, favorites, refresh } = get(); + if (!client) return false; const node = allFolders.find(f => f.id === id); const oldPath = folderPath(allFolders, id); + const known = node ?? resources.find(r => r.id === id); + const file = known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id); + if (!(await fileHooks.onBeforeFileRename.intercept({ file, newName }))) return false; await client.updateFileNode(id, { name: newName }); + void fileHooks.onFileRename.emit({ file: { ...file, name: newName }, previousName: file.name }); // Starred folders are kept by path: follow the rename. if (oldPath) { const newPath = oldPath.slice(0, oldPath.lastIndexOf('/') + 1) + newName; @@ -1163,13 +1324,19 @@ export const useFileStore = create((set, get) => ({ set({ currentPath: buildPathFromStack(get().pathStack) }); } await refresh(); + return true; }, deleteNode: async (id: string) => { - const { client, allFolders, favorites, recentFiles, pathStack, navigate, refresh } = get(); - if (!client) return; + const { client, allFolders, resources, favorites, recentFiles, pathStack, navigate, refresh } = get(); + if (!client) return false; const path = folderPath(allFolders, id); - await client.destroyFileNodes([id]); + const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id); + const files = [known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id)]; + if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false; + const result = await client.destroyFileNodes([id]); + const deleted = destroyedViews(files, result); + if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted }); const nextFavorites = path ? favorites.filter(f => !isUnder(f, path)) : favorites; // Recent files only know their own id; drop the one deleted, the rest are @@ -1187,9 +1354,10 @@ export const useFileStore = create((set, get) => ({ const parent = pathStack[idx - 1]; set({ pathStack: pathStack.slice(0, idx) }); await navigate(parent.id, parent.name); - return; + return true; } await refresh(); + return true; }, downloadNode: async (id: string) => { @@ -1201,6 +1369,8 @@ export const useFileStore = create((set, get) => ({ const node = nodes.find(n => n.id === id); if (!node?.blobId) throw new Error('File not found'); await client.downloadBlob(node.blobId, node.name, node.type); + const folders = nodes.filter(isFolder).map(nodeToResource); + void fileHooks.onFileDownload.emit(fileToView(node, parentPath(folders, node.parentId))); }, removeRecentFile: (id: string) => { From 8e2f9d7c94f483b39e23d4baf1b35c50407fe24f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:36:35 +0200 Subject: [PATCH 044/147] feat: tell plugins when the calendar view, its calendars or subscriptions change --- .../calendar-store-view-hooks.test.ts | 156 ++++++++++++++++++ stores/calendar-store.ts | 58 +++++-- 2 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 stores/__tests__/calendar-store-view-hooks.test.ts diff --git a/stores/__tests__/calendar-store-view-hooks.test.ts b/stores/__tests__/calendar-store-view-hooks.test.ts new file mode 100644 index 000000000..a8b22f1c2 --- /dev/null +++ b/stores/__tests__/calendar-store-view-hooks.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useCalendarStore } from '../calendar-store'; +import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar } from '@/lib/jmap/types'; + +// Plugins hear when the shown day or view changes, when calendars are +// created, changed, removed, shown or hidden, and when an iCal subscription +// is added, changed, removed or refreshed. + +function cal(overrides: Partial = {}): Calendar { + return { id: 'c1', name: 'Work', isDefault: false, ...overrides } as Calendar; +} + +function fakeClient(overrides: Partial> = {}) { + return { + createCalendar: vi.fn(async (c: Partial) => ({ ...c, id: 'new-cal' })), + updateCalendar: vi.fn().mockResolvedValue(undefined), + deleteCalendar: vi.fn().mockResolvedValue(undefined), + setDefaultCalendar: vi.fn().mockResolvedValue(undefined), + setCalendarShare: vi.fn().mockResolvedValue(undefined), + getAccountId: () => 'acct', + getCalendarsAccountId: () => 'acct', + getServerUrl: () => 'https://mail.example', + getUsername: () => 'me@example.com', + getCalendars: async () => [{ id: 'sub-cal', name: 'Feed' }], + uploadBlob: async () => ({ blobId: 'blob' }), + parseCalendarEvents: async () => [], + getCalendarEvents: async () => [], + batchDeleteCalendarEvents: vi.fn(), + ...overrides, + } as unknown as IJMAPClient & Record>; +} + +beforeEach(() => { + clearAllHooks(); + useCalendarStore.getState().clearState(); + useCalendarStore.setState({ + calendars: [cal()], selectedCalendarIds: ['c1'], icalSubscriptions: [], error: null, dateRange: null, + selectedDate: new Date(2026, 9, 12, 9, 0), viewMode: 'month', + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response('BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', { status: 200 }))); +}); + +describe('date and view hooks', () => { + it('reports a new day, but not the same day again', () => { + const handler = vi.fn(); + calendarHooks.onCalendarDateChange.register('t', handler); + useCalendarStore.getState().setSelectedDate(new Date(2026, 9, 12, 15, 0)); + expect(handler).not.toHaveBeenCalled(); + useCalendarStore.getState().setSelectedDate(new Date(2026, 9, 13, 9, 0)); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ date: '2026-10-13' }); + }); + + it('reports a new view, but not the same view again', () => { + const handler = vi.fn(); + calendarHooks.onCalendarViewChange.register('t', handler); + useCalendarStore.getState().setViewMode('month'); + expect(handler).not.toHaveBeenCalled(); + useCalendarStore.getState().setViewMode('week'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ view: 'week' }); + }); +}); + +describe('calendar hooks', () => { + it('reports created, updated and removed calendars', async () => { + const handler = vi.fn(); + calendarHooks.onCalendarChange.register('t', handler); + const client = fakeClient(); + + await useCalendarStore.getState().createCalendar(client, { name: 'New' }); + await useCalendarStore.getState().updateCalendar(client, 'c1', { name: 'Renamed' }); + await useCalendarStore.getState().setDefaultCalendar(client, 'c1'); + await useCalendarStore.getState().removeCalendar(client, 'c1'); + + expect(handler.mock.calls.map((c) => c[0])).toEqual([ + { calendarId: 'new-cal', change: 'created' }, + { calendarId: 'c1', change: 'updated' }, + { calendarId: 'c1', change: 'updated' }, + { calendarId: 'c1', change: 'removed' }, + ]); + }); + + it('does not report calendar changes the server refused', async () => { + const handler = vi.fn(); + calendarHooks.onCalendarChange.register('t', handler); + const fail = vi.fn().mockRejectedValue(new Error('boom')); + const client = fakeClient({ createCalendar: fail, updateCalendar: fail, deleteCalendar: fail }); + + await useCalendarStore.getState().createCalendar(client, { name: 'New' }); + await expect(useCalendarStore.getState().updateCalendar(client, 'c1', { name: 'X' })).rejects.toThrow(); + await expect(useCalendarStore.getState().removeCalendar(client, 'c1')).rejects.toThrow(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('reports a calendar being hidden and shown', () => { + const handler = vi.fn(); + calendarHooks.onCalendarVisibilityToggle.register('t', handler); + useCalendarStore.getState().toggleCalendarVisibility('c1'); + useCalendarStore.getState().toggleCalendarVisibility('c1'); + expect(handler.mock.calls.map((c) => c[0])).toEqual([ + { calendarId: 'c1', visible: false }, + { calendarId: 'c1', visible: true }, + ]); + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['c1']); + }); +}); + +describe('iCal subscription hooks', () => { + it('reports an added subscription once, without a separate refresh', async () => { + const handler = vi.fn(); + calendarHooks.onICalSubscriptionChange.register('t', handler); + const client = fakeClient({ createCalendar: vi.fn(async () => ({ id: 'sub-cal', name: 'Feed' })) }); + + const sub = await useCalendarStore.getState().addICalSubscription(client, 'webcal://feeds.example/x.ics', 'Feed', '#f00'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ subscriptionId: sub!.id, change: 'added' }); + }); + + it('does not report a subscription whose first fetch failed', async () => { + const handler = vi.fn(); + calendarHooks.onICalSubscriptionChange.register('t', handler); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 }))); + const client = fakeClient({ createCalendar: vi.fn(async () => ({ id: 'sub-cal', name: 'Feed' })) }); + + await expect(useCalendarStore.getState().addICalSubscription(client, 'https://feeds.example/x.ics', 'Feed', '#f00')).rejects.toThrow(); + expect(handler).not.toHaveBeenCalled(); + }); + + it('reports refreshed, updated and removed subscriptions', async () => { + const client = fakeClient(); + useCalendarStore.setState({ + calendars: [cal({ id: 'sub-cal', name: 'Feed' })], + icalSubscriptions: [{ + id: 's1', url: 'https://feeds.example/x.ics', calendarId: 'sub-cal', accountId: 'acct', + owner: 'https://mail.example|me@example.com', name: 'Feed', color: '#f00', refreshInterval: 60, lastRefreshed: null, + }], + }); + const handler = vi.fn(); + calendarHooks.onICalSubscriptionChange.register('t', handler); + + await useCalendarStore.getState().refreshICalSubscription(client, 's1'); + await useCalendarStore.getState().updateICalSubscription(client, 's1', { name: 'Renamed' }); + await useCalendarStore.getState().removeICalSubscription(client, 's1'); + + expect(handler.mock.calls.map((c) => c[0])).toEqual([ + { subscriptionId: 's1', change: 'refreshed' }, + { subscriptionId: 's1', change: 'updated' }, + { subscriptionId: 's1', change: 'removed' }, + ]); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 74b2d2583..487febf0e 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -20,7 +20,7 @@ import { isServerRecurrenceInstance, isSyntheticIdMutationUnsupported, } from '@/lib/recurrence-instances'; -import { parseISO } from 'date-fns'; +import { format, parseISO } from 'date-fns'; import { generateUUID } from '@/lib/utils'; import { apiFetch } from '@/lib/browser-navigation'; import { explainServerAuthError } from '@/lib/server-auth-status'; @@ -347,6 +347,8 @@ function selectionAfterFetch( // manual refresh, two account-switch reloads, etc.) share the same // promise instead of double-fetching and racing the diff/import phase. const refreshInFlight = new Map>(); +// Subscriptions whose first fetch is running inside addICalSubscription. +const subscriptionsBeingAdded = new Set(); export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; @@ -1091,8 +1093,22 @@ export const useCalendarStore = create()( } }, - setSelectedDate: (date) => set({ selectedDate: date }), - setViewMode: (mode) => set({ viewMode: getSafeCalendarViewMode(mode) }), + setSelectedDate: (date) => { + // Plugins get the shown day; a new time on the same day is no change. + const previous = get().selectedDate; + set({ selectedDate: date }); + if (Number.isNaN(date.getTime())) return; + const day = format(date, 'yyyy-MM-dd'); + if (Number.isNaN(previous.getTime()) || format(previous, 'yyyy-MM-dd') !== day) { + void calendarHooks.onCalendarDateChange.emit({ date: day }); + } + }, + setViewMode: (mode) => { + const view = getSafeCalendarViewMode(mode); + const previous = get().viewMode; + set({ viewMode: view }); + if (previous !== view) void calendarHooks.onCalendarViewChange.emit({ view }); + }, updateCalendar: async (client, calendarId, updates) => { set({ error: null }); @@ -1107,6 +1123,7 @@ export const useCalendarStore = create()( c.id === calendarId ? { ...c, ...updates } : c ), })); + void calendarHooks.onCalendarChange.emit({ calendarId, change: 'updated' }); } catch (error) { debug.error('Failed to update calendar:', error); set({ error: 'Failed to update calendar' }); @@ -1137,6 +1154,7 @@ export const useCalendarStore = create()( return c; }), })); + void calendarHooks.onCalendarChange.emit({ calendarId, change: 'updated' }); } catch (error) { debug.error('Failed to set default calendar:', error); set({ error: 'Failed to set default calendar' }); @@ -1161,6 +1179,7 @@ export const useCalendarStore = create()( return { ...c, shareWith: next }; }), })); + void calendarHooks.onCalendarChange.emit({ calendarId, change: 'updated' }); } catch (error) { debug.error('Failed to share calendar:', error); set({ error: 'Failed to share calendar' }); @@ -1178,6 +1197,7 @@ export const useCalendarStore = create()( calendars: [...state.calendars, created], selectedCalendarIds: [...state.selectedCalendarIds, created.id], })); + void calendarHooks.onCalendarChange.emit({ calendarId: created.id, change: 'created' }); return created; } catch (error) { debug.error('Failed to create calendar:', error); @@ -1199,6 +1219,7 @@ export const useCalendarStore = create()( selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId), events: state.events.filter(e => !e.calendarIds?.[calendarId]), })); + void calendarHooks.onCalendarChange.emit({ calendarId, change: 'removed' }); } catch (error) { debug.error('Failed to delete calendar:', error); set({ error: 'Failed to delete calendar' }); @@ -1286,14 +1307,15 @@ export const useCalendarStore = create()( } }, - toggleCalendarVisibility: (calendarId) => set((state) => { - const ids = state.selectedCalendarIds; - return { - selectedCalendarIds: ids.includes(calendarId) - ? ids.filter(id => id !== calendarId) - : [...ids, calendarId], - }; - }), + toggleCalendarVisibility: (calendarId) => { + const visible = !get().selectedCalendarIds.includes(calendarId); + set((state) => ({ + selectedCalendarIds: visible + ? [...state.selectedCalendarIds, calendarId] + : state.selectedCalendarIds.filter(id => id !== calendarId), + })); + void calendarHooks.onCalendarVisibilityToggle.emit({ calendarId, visible }); + }, setSelectedEventId: (id) => set({ selectedEventId: id }), @@ -1350,8 +1372,15 @@ export const useCalendarStore = create()( // Initial fetch - roll back the calendar create if it fails so we // don't leave a phantom calendar around after a bad URL / 404 / etc. - await get().refreshICalSubscription(client, subscription.id); + // Plugins hear "added" for it, not also "refreshed". + subscriptionsBeingAdded.add(subscription.id); + try { + await get().refreshICalSubscription(client, subscription.id); + } finally { + subscriptionsBeingAdded.delete(subscription.id); + } + void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId: subscription.id, change: 'added' }); return subscription; } catch (error) { debug.error('Failed to add iCal subscription:', error); @@ -1409,6 +1438,7 @@ export const useCalendarStore = create()( }; }), })); + void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId, change: 'updated' }); // If URL changed, refresh to fetch events from new source if (normalizedUpdates.url && normalizedUpdates.url !== sub.url) { @@ -1437,6 +1467,7 @@ export const useCalendarStore = create()( selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== sub.calendarId), events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]), })); + void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId, change: 'removed' }); }, refreshICalSubscription: async (client, subscriptionId) => { @@ -1544,6 +1575,9 @@ export const useCalendarStore = create()( refreshInFlight.set(subscriptionId, work); try { await work; + if (!subscriptionsBeingAdded.has(subscriptionId)) { + void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId, change: 'refreshed' }); + } } catch (error) { debug.error('Failed to refresh iCal subscription:', sub.name, error); throw error; From 69dbc1b6615be43e349466c07c8679880b1d64e9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:39:13 +0200 Subject: [PATCH 045/147] feat: tell plugins when a message body is on screen or a conversation unfolds, and let them rewrite links --- .../__tests__/viewer-read-hooks.test.tsx | 94 +++++++++++++++ components/email/email-list.tsx | 23 +++- components/email/email-viewer.tsx | 57 +++++++-- components/email/thread-conversation-view.tsx | 3 + components/mail/mail-app.tsx | 6 + components/ui/avatar.tsx | 3 +- lib/__tests__/plugin-hook-result.test.ts | 97 +++++++++++++++ lib/__tests__/plugin-thread-expand.test.ts | 22 ++++ lib/plugin-hook-result.ts | 111 ++++++++++++++++++ lib/plugin-thread-expand.ts | 22 ++++ 10 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 components/email/__tests__/viewer-read-hooks.test.tsx create mode 100644 lib/__tests__/plugin-hook-result.test.ts create mode 100644 lib/__tests__/plugin-thread-expand.test.ts create mode 100644 lib/plugin-hook-result.ts create mode 100644 lib/plugin-thread-expand.ts diff --git a/components/email/__tests__/viewer-read-hooks.test.tsx b/components/email/__tests__/viewer-read-hooks.test.tsx new file mode 100644 index 000000000..6437c4f4e --- /dev/null +++ b/components/email/__tests__/viewer-read-hooks.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks'; + +// mail-read hooks fired by the reading pane: onEmailContentRender once the +// body is on screen and onThreadExpand for the conversation cards. (The link +// hook runs inside the body iframe, which jsdom doesn't load; its decision is +// covered in lib/__tests__/plugin-hook-result.test.ts.) + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const textEmail = (id = 'e1') => ({ + id, blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: 'Hello there' } }, +}) as unknown as Email; + +const viewer = (email: Email, extra: Record = {}) => ( + +); + +beforeEach(() => clearAllHooks()); + +describe('onEmailContentRender', () => { + it('fires once the body is on screen, once per message', async () => { + const handler = vi.fn(); + emailHooks.onEmailContentRender.register('test', handler); + + const { rerender } = render(viewer(textEmail(), { isLoading: true })); + await act(async () => {}); + expect(handler).not.toHaveBeenCalled(); + + rerender(viewer(textEmail(), { isLoading: false })); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler).toHaveBeenCalledWith({ emailId: 'e1', hasHtml: false, hasExternalContent: false }); + + rerender(viewer(textEmail(), { isLoading: false, onReply: () => {} })); + await act(async () => {}); + expect(handler).toHaveBeenCalledTimes(1); + + rerender(viewer(textEmail('e2'), { isLoading: false })); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenLastCalledWith({ emailId: 'e2', hasHtml: false, hasExternalContent: false }); + }); +}); + +describe('onThreadExpand in the conversation view', () => { + it('reports a card being unfolded and folded', () => { + const handler = vi.fn(); + emailHooks.onThreadExpand.register('test', handler); + const older = { ...textEmail('old'), keywords: { $seen: true }, receivedAt: '2025-12-01T00:00:00Z' } as Email; + const newer = textEmail('new'); + + render( + {}} + />, + ); + // The newest card starts unfolded, the older read one folded. + const olderHeader = screen.getAllByText('Alice').at(-1)!; + fireEvent.click(olderHeader); + expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: true }); + fireEvent.click(screen.getAllByText('Alice').at(-1)!); + expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: false }); + expect(handler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5e33fd79b..25bb80857 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -31,6 +31,7 @@ import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-displa import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; import { getOwnAddresses } from "@/lib/filters/quick-rule-target"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { normalizeAddress } from "@/lib/filters/quick-rules"; interface EmailListProps { @@ -497,18 +498,34 @@ export function EmailList({ } }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]); + // The messages of a thread as the list knows them, for onThreadExpand. + const threadMembers = useCallback((threadKey: string): Email[] => ( + useEmailStore.getState().threadEmailsCache.get(threadKey) + ?? threadGroups.find((g) => g.threadKey === threadKey)?.emails + ?? [] + ), [threadGroups]); + const handleToggleThreadExpansion = useCallback(async (threadKey: string) => { const isExpanded = expandedThreadIds.has(threadKey); if (!isExpanded && client) { toggleThreadExpansion(threadKey); await fetchThreadEmails(client, threadKey); + emitThreadExpand(threadKey, threadMembers(threadKey), true); // Mark all unread emails in this thread as read void markThreadAsRead(client, threadKey); } else { toggleThreadExpansion(threadKey); + emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded); } - }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]); + }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]); + + const collapseAllThreadsAndNotify = useCallback(() => { + const wereExpanded = [...useEmailStore.getState().expandedThreadIds]; + const members = wereExpanded.map((key) => [key, threadMembers(key)] as const); + collapseAllThreads(); + for (const [key, emails] of members) emitThreadExpand(key, emails, false); + }, [collapseAllThreads, threadMembers]); // Range-based load more: trigger when last visible item is near the end. // Debounce to prevent rapid cascade when thread grouping reduces item @@ -756,14 +773,14 @@ export function EmailList({ isLoading={isLoadingThread === thread.threadKey} expandedEmails={threadEmailsCache.get(thread.threadKey)} onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)} - onCollapseAllThreads={collapseAllThreads} + onCollapseAllThreads={collapseAllThreadsAndNotify} onEmailSelect={(email) => { // Collapse expanded threads when selecting an email outside the expanded thread const currentExpanded = useEmailStore.getState().expandedThreadIds; if (currentExpanded.size > 0) { // Check if the selected email belongs to any expanded thread via its threadId if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) { - collapseAllThreads(); + collapseAllThreadsAndNotify(); } } onEmailSelect?.(email); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8fc8e9118..4278020e5 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4,7 +4,7 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; @@ -26,7 +26,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getEmailTagIds } from "@/lib/thread-utils"; import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; -import type { EmailReadView } from "@/lib/plugin-types"; +import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { generateEmailSource } from "@/lib/email-source"; import { Reply, @@ -124,7 +124,9 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { RulesPanel, useRulesAvailability } from "./rules-menu"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { emailHooks, uiHooks, renderHooks } from "@/lib/plugin-hooks"; +import { emailHooks, renderHooks } from "@/lib/plugin-hooks"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; +import { resolveExternalLink } from "@/lib/plugin-hook-result"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; @@ -1123,6 +1125,8 @@ export function EmailViewer({ // conversation opens with its unread messages unfolded, as Gmail does, so // reading the thread reads all of them. const [mvExpandedIds, setMvExpandedIds] = useState>(() => new Set()); + const mvExpandedIdsRef = useRef(mvExpandedIds); + mvExpandedIdsRef.current = mvExpandedIds; const mvExpandedThreadRef = useRef(null); useEffect(() => { if (!mvConversation || mvConversation.length === 0 || !email) { @@ -1135,6 +1139,7 @@ export function EmailViewer({ setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id))); }, [mvConversation, email]); const toggleMvExpanded = useCallback((target: Email) => { + emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id)); setMvExpandedIds((prev) => { const next = new Set(prev); if (next.has(target.id)) next.delete(target.id); @@ -1146,6 +1151,12 @@ export function EmailViewer({ const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); useEffect(() => setMvOpenCollapsed(false), [email?.id]); const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed; + // Folding or unfolding the open message of a conversation, told to plugins + // like any other conversation message (onThreadExpand). + const setMvOpenFolded = (folded: boolean) => { + if (email) emitThreadExpand(email.threadId, [email], !folded); + setMvOpenCollapsed(folded); + }; // The conversation is not here yet (the one on hand is another thread's, or // none). Hold the message back instead of drawing it alone and then pushing // it down under the earlier messages. @@ -2789,6 +2800,30 @@ export function EmailViewer({ staleDocRef.current = iframeRef.current?.contentDocument ?? null; }, [emailIframeSrcDoc]); + // onEmailContentRender: once per opened message, after its body is on + // screen (the iframe has loaded an HTML body; a text body renders at once). + const contentRenderedForRef = useRef(null); + useEffect(() => { + if (!email || isBodyLoading) return; + if (effectiveEmailContent.isHtml && !iframeReady) return; + if (contentRenderedForRef.current === email.id) return; + contentRenderedForRef.current = email.id; + if (emailHooks.onEmailContentRender.size === 0) return; + let hasExternalContent = hasBlockedContent; + if (!hasExternalContent && effectiveEmailContent.isHtml) { + // Loaded rather than blocked (allowed by the user or the policy): look + // at what the rendered body actually references. + const doc = iframeRef.current?.contentDocument; + hasExternalContent = !!doc && Array.from(doc.querySelectorAll('[src], [srcset], [background], [poster]')).some((el) => + ['src', 'srcset', 'background', 'poster'].some((attr) => isExternalResourceUrl(el.getAttribute(attr)))); + } + void emailHooks.onEmailContentRender.emit({ + emailId: email.id, + hasHtml: effectiveEmailContent.isHtml, + hasExternalContent, + }); + }, [email, isBodyLoading, effectiveEmailContent.isHtml, iframeReady, hasBlockedContent]); + // Open a blob: link the rendered body points at a cid: part. Mirrors the // attachment-chip gate: inert previewable types open in a tab, everything // else (and any blob: URL we did not mint) is downloaded under its part name. @@ -2929,14 +2964,16 @@ export function EmailViewer({ // data:, unresolved cid: and relative paths would land inside our // own origin. if (!isOpenableLinkHref(href)) return; - const ctx = { + const ctx: ExternalLinkContext = { href, target: targetEl.getAttribute('target') ?? undefined, emailId: email?.id, }; - const ok = await uiHooks.onBeforeExternalLink.intercept(ctx); - if (!ok || !isOpenableLinkHref(ctx.href)) return; - window.open(ctx.href, '_blank', 'noopener,noreferrer'); + // A plugin returns false to stop the link or { href } to send it + // somewhere else (a web address only). + const target = await resolveExternalLink(ctx); + if (!target) return; + window.open(target, '_blank', 'noopener,noreferrer'); }; doc.addEventListener('click', onLinkClick, true); @@ -4430,7 +4467,7 @@ export function EmailViewer({ {/* Mountain View: the open message folded like the rest of the conversation. */} {mvOpenFolded && email ? (
- setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} /> + setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
) : mvAwaitingConversation ? (
@@ -4452,7 +4489,7 @@ export function EmailViewer({ // message, as Gmail does; its buttons and links keep their own job. if (isInteractiveClick(e.target, e.currentTarget)) return; if (window.getSelection()?.toString()) return; - setMvOpenCollapsed(true); + setMvOpenFolded(true); } : undefined} >
@@ -4770,7 +4807,7 @@ export function EmailViewer({ onClick={mvOpenIndex >= 0 ? (e) => { if (isInteractiveClick(e.target, e.currentTarget)) return; if (window.getSelection()?.toString()) return; - setMvOpenCollapsed(true); + setMvOpenFolded(true); } : undefined} >
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4a4f70d43..72d3db894 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -41,6 +41,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { isFilePreviewable, toInertBlob } from "@/lib/file-preview"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; interface ThreadConversationViewProps { thread: ThreadGroup; @@ -193,6 +194,8 @@ export function ThreadConversationView({ }, [emails]); const toggleExpanded = (emailId: string) => { + const target = emails.find((e) => e.id === emailId); + if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId)); setExpandedIds(prev => { const next = new Set(prev); if (next.has(emailId)) { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 385bbbdaa..7ebd78726 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -118,6 +118,7 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; @@ -971,10 +972,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const store = useEmailStore.getState(); const wasExpanded = store.expandedThreadIds.has(threadKey); store.toggleThreadExpansion(threadKey); + const members = () => useEmailStore.getState().threadEmailsCache.get(threadKey) + ?? activeEmails.filter((e) => e.threadId && threadKeyFor(e) === threadKey); if (!wasExpanded) { void store.fetchThreadEmails(client, threadKey).then(() => { + emitThreadExpand(threadKey, members(), true); void useEmailStore.getState().markThreadAsRead(client, threadKey); }); + } else { + emitThreadExpand(threadKey, members(), false); } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index ce1ad23df..2478c78a6 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -165,8 +165,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d setPluginAvatarFailed(false); if (!email || avatarHooks.onAvatarResolve.size === 0) return; let cancelled = false; - avatarHooks.onAvatarResolve - .transform(null as string | null, { email, name }) + avatarHooks.onAvatarResolve.transform(null as string | null, { email, name }) .then((url) => { if (!cancelled) setPluginAvatarUrl(url); }) .catch(() => { if (!cancelled) setPluginAvatarFailed(true); }); return () => { cancelled = true; }; diff --git a/lib/__tests__/plugin-hook-result.test.ts b/lib/__tests__/plugin-hook-result.test.ts new file mode 100644 index 000000000..81c1d38dd --- /dev/null +++ b/lib/__tests__/plugin-hook-result.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, uiHooks, pluginErrorTracker } from '../plugin-hooks'; +import { interceptWithResult, acceptLinkRewrite, resolveExternalLink } from '../plugin-hook-result'; + +const plainIntercept = (v: unknown) => uiHooks.onBeforeExternalLink.intercept(v); + +beforeEach(() => { + clearAllHooks(); + pluginErrorTracker.resetAll(); +}); + +describe('interceptWithResult', () => { + it('lets the action through unchanged without handlers', async () => { + const ctx = { href: 'https://a.example/' }; + expect(await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, plainIntercept)) + .toEqual({ allowed: true, value: ctx }); + }); + + it('stops on false and does not ask later handlers', async () => { + const later = vi.fn(); + uiHooks.onBeforeExternalLink.register('a', () => false); + uiHooks.onBeforeExternalLink.register('b', later); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + expect(out).toEqual({ allowed: false }); + expect(later).not.toHaveBeenCalled(); + }); + + it('takes a returned { href } and hands it to the next handler', async () => { + const second = vi.fn(() => undefined); + uiHooks.onBeforeExternalLink.register('a', () => ({ href: 'https://proxy.example/?u=1' })); + uiHooks.onBeforeExternalLink.register('b', second); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/', emailId: 'e1' }, acceptLinkRewrite, plainIntercept); + expect(out).toEqual({ allowed: true, value: { href: 'https://proxy.example/?u=1', emailId: 'e1' } }); + expect(second).toHaveBeenCalledWith({ href: 'https://proxy.example/?u=1', emailId: 'e1' }); + }); + + it('calls every handler once', async () => { + const handler = vi.fn(() => ({ href: 'https://b.example/' })); + uiHooks.onBeforeExternalLink.register('a', handler); + await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('skips a handler that throws (fails open)', async () => { + uiHooks.onBeforeExternalLink.register('a', () => { throw new Error('boom'); }); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + expect(out).toEqual({ allowed: true, value: { href: 'https://a.example/' } }); + }); +}); + +describe('acceptLinkRewrite', () => { + const cur = { href: 'https://a.example/' }; + it.each([ + 'javascript:alert(1)', + 'data:text/html,hi', + '/settings', + 'mailto:x@example.com', + 'https://', + ])('ignores %s', (href) => { + expect(acceptLinkRewrite({ href }, cur)).toBeUndefined(); + }); + + it('ignores anything but an object with a string href', () => { + expect(acceptLinkRewrite('https://b.example/', cur)).toBeUndefined(); + expect(acceptLinkRewrite({ href: 42 }, cur)).toBeUndefined(); + }); + + it('takes an http(s) address', () => { + expect(acceptLinkRewrite({ href: ' HTTP://b.example/x ' }, cur)).toEqual({ href: 'HTTP://b.example/x' }); + }); +}); + +describe('resolveExternalLink (a link clicked in a message)', () => { + const ctx = { href: 'https://a.example/', emailId: 'e1' }; + + it('opens the original address without plugins', async () => { + expect(await resolveExternalLink(ctx)).toBe('https://a.example/'); + }); + + it('opens the address a plugin returns', async () => { + const handler = vi.fn(() => ({ href: 'https://proxy.example/?u=a' })); + uiHooks.onBeforeExternalLink.register('p', handler); + expect(await resolveExternalLink(ctx)).toBe('https://proxy.example/?u=a'); + expect(handler).toHaveBeenCalledWith(ctx); + }); + + it('opens nothing when a plugin returns false', async () => { + uiHooks.onBeforeExternalLink.register('p', () => false); + expect(await resolveExternalLink(ctx)).toBeNull(); + }); + + it('keeps the original when the rewrite is not a web address', async () => { + uiHooks.onBeforeExternalLink.register('p', () => ({ href: 'javascript:alert(1)' })); + expect(await resolveExternalLink(ctx)).toBe('https://a.example/'); + }); +}); diff --git a/lib/__tests__/plugin-thread-expand.test.ts b/lib/__tests__/plugin-thread-expand.test.ts new file mode 100644 index 000000000..109df770e --- /dev/null +++ b/lib/__tests__/plugin-thread-expand.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { emailHooks, clearAllHooks } from '../plugin-hooks'; +import { emitThreadExpand } from '../plugin-thread-expand'; + +beforeEach(() => clearAllHooks()); + +describe('emitThreadExpand', () => { + it('reports the thread id and its messages', () => { + const handler = vi.fn(); + emailHooks.onThreadExpand.register('test', handler); + emitThreadExpand('acct/x:T1', [{ id: 'a', threadId: 'T1' }, { id: 'b', threadId: 'T1' }], true); + expect(handler).toHaveBeenCalledWith({ threadId: 'T1', emailIds: ['a', 'b'], expanded: true }); + }); + + it('falls back to the id inside an account-scoped thread key', () => { + const handler = vi.fn(); + emailHooks.onThreadExpand.register('test', handler); + emitThreadExpand('acct/x:T1', [], false); + expect(handler).toHaveBeenCalledWith({ threadId: 'T1', emailIds: [], expanded: false }); + }); +}); diff --git a/lib/plugin-hook-result.ts b/lib/plugin-hook-result.ts new file mode 100644 index 000000000..1c532885c --- /dev/null +++ b/lib/plugin-hook-result.ts @@ -0,0 +1,111 @@ +// "Before" hooks whose handlers may either stop an action (return false) or +// change what it does (return a value), such as onBeforeExternalLink +// (`{ href }`) and onBeforeCompose (a ComposeOptions to prefill). +// +// HookBus.intercept() only reports whether the action may go ahead and drops +// every other return value; HookBus.transform() keeps return values but +// ignores `false`. Running both would call every plugin twice (and show any +// confirmation dialog twice), so this walks the handlers once and honours +// both answers. A payload changed in place has no effect: plugin handlers run +// in a sandboxed iframe and receive a copy. + +import { pluginErrorTracker, uiHooks, type HookBus } from './plugin-hooks'; +import type { ExternalLinkContext } from './plugin-types'; +import { isOpenableLinkHref } from './email-sanitization'; + +// Same budget as HookBus.intercept: these hooks may wait on a dialog. +const INTERCEPT_TIMEOUT_MS = 60_000; + +interface Entry { + pluginId: string; + handler: (...args: unknown[]) => unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function handlersOf(bus: HookBus): Entry[] | null { + const handlers = (bus as unknown as { handlers?: unknown }).handlers; + return Array.isArray(handlers) ? [...(handlers as Entry[])] : null; +} + +function withTimeout(value: T | Promise, ms: number): Promise { + if (!(value instanceof Promise)) return Promise.resolve(value); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); + value.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +export type InterceptResult = { allowed: false } | { allowed: true; value: V }; + +/** + * Ask every handler of an intercept bus, in order. `false` stops the action; + * any other return value goes through `accept`, which turns it into the next + * value or returns undefined to ignore it. Like `intercept`, a handler that + * throws or times out is skipped (fail open). + * + * `plainIntercept` is the bus's own `intercept`, used when its handlers can't + * be walked (a test double): the veto still works, return values are lost. + */ +export async function interceptWithResult( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bus: HookBus, + value: V, + accept: (result: unknown, current: V) => V | undefined, + plainIntercept: (value: V) => Promise, +): Promise> { + // No listeners (or a test double without a size): nothing to ask. + if (!(bus && bus.size > 0)) return { allowed: true, value }; + const handlers = handlersOf(bus); + if (!handlers) { + return (await plainIntercept(value)) ? { allowed: true, value } : { allowed: false }; + } + let current = value; + for (const { pluginId, handler } of handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + let result: unknown; + try { + result = await withTimeout(handler(current), INTERCEPT_TIMEOUT_MS); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + continue; + } + if (result === false) return { allowed: false }; + if (result === undefined || result === null || result === true) continue; + const next = accept(result, current); + if (next !== undefined) current = next; + } + return { allowed: true, value: current }; +} + +/** + * `accept` for onBeforeExternalLink: a handler may return `{ href }` to send + * the click elsewhere. Only a web address is taken, so a plugin can't turn a + * link into javascript:, data: or a path inside the app. + */ +export function acceptLinkRewrite(result: unknown, current: T): T | undefined { + if (!result || typeof result !== 'object') return undefined; + const href = (result as { href?: unknown }).href; + if (typeof href !== 'string') return undefined; + const trimmed = href.trim(); + if (!/^https?:/i.test(trimmed)) return undefined; + try { + new URL(trimmed); + } catch { + return undefined; + } + return { ...current, href: trimmed }; +} + +/** + * Ask onBeforeExternalLink about a click on a link in a message. Resolves to + * the address to open: the original, the one a plugin rewrote it to, or null + * when a plugin stopped it. + */ +export async function resolveExternalLink(ctx: ExternalLinkContext): Promise { + const outcome = await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, (c) => uiHooks.onBeforeExternalLink.intercept(c)); + if (!outcome.allowed || !isOpenableLinkHref(outcome.value.href)) return null; + return outcome.value.href; +} diff --git a/lib/plugin-thread-expand.ts b/lib/plugin-thread-expand.ts new file mode 100644 index 000000000..cad1b9b5d --- /dev/null +++ b/lib/plugin-thread-expand.ts @@ -0,0 +1,22 @@ +// onThreadExpand: a conversation was expanded or collapsed, in the message +// list or in the reading pane. Shared by every place that folds a thread so +// they all report the same payload. + +import type { Email } from './jmap/types'; +import { emailHooks } from './plugin-hooks'; +import { threadIdFromKey } from './thread-utils'; + +/** + * Tell plugins a conversation was expanded or collapsed. `emails` are the + * messages that were unfolded or folded: the whole thread for a list row, one + * message for a card in the reading pane. + */ +export function emitThreadExpand( + threadKey: string, + emails: ReadonlyArray>, + expanded: boolean, +): void { + if (emailHooks.onThreadExpand.size === 0) return; + const threadId = emails[0]?.threadId || threadIdFromKey(threadKey); + void emailHooks.onThreadExpand.emit({ threadId, emailIds: emails.map((e) => e.id), expanded }); +} From 6b98fccead19fadbe0708b649412ce1a7e23fa90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:39:30 +0200 Subject: [PATCH 046/147] feat: tell plugins when a folder is opened, files are selected or starred, and an action is undone --- stores/__tests__/file-store-hooks.test.ts | 96 +++++++++++++++++++++++ stores/file-store.ts | 48 ++++++++++-- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/stores/__tests__/file-store-hooks.test.ts b/stores/__tests__/file-store-hooks.test.ts index 39798efaa..aa31ec20d 100644 --- a/stores/__tests__/file-store-hooks.test.ts +++ b/stores/__tests__/file-store-hooks.test.ts @@ -385,3 +385,99 @@ describe('file-store plugin hooks: changing files', () => { }); }); }); + +describe('file-store plugin hooks: navigation, selection, favourites, undo', () => { + beforeEach(() => clearAllHooks()); + + it('reports each newly opened folder, but not a refresh', async () => { + const navigated = vi.fn(); + fileHooks.onFileNavigate.register('test', navigated); + + await openDocs(); + await useFileStore.getState().refresh(); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().navigateUp(); + await useFileStore.getState().navigateByPath('/'); + await flush(); + + expect(navigated.mock.calls.map(c => c[0])).toEqual([ + { path: '/Docs' }, + { path: '/Docs/Target' }, + { path: '/Docs' }, + { path: '/' }, + ]); + }); + + it('does not report a folder that failed to load', async () => { + const client = await openDocs(); + client.listAllFileNodes.mockRejectedValueOnce(new Error('offline')); + const navigated = vi.fn(); + fileHooks.onFileNavigate.register('test', navigated); + + await useFileStore.getState().navigate('target', 'Target'); + await flush(); + expect(navigated).not.toHaveBeenCalled(); + }); + + it('reports selection changes with the selected files, and only real ones', async () => { + await openDocs(); + const selection = vi.fn(); + fileHooks.onFileSelectionChange.register('test', selection); + const store = useFileStore.getState(); + + store.selectResource('a.txt'); + store.selectResource('a.txt'); // same: no change + store.toggleSelect('b.txt'); + store.setSelection(new Set(['a.txt', 'b.txt'])); // same: no change + store.clearSelection(); + store.selectAll(); + await flush(); + + const calls = selection.mock.calls.map(c => c[0].files.map((f: { id: string }) => f.id).sort()); + expect(calls).toEqual([['a'], ['a', 'b'], [], ['a', 'b', 'target']]); + expect(selection.mock.calls[0][0].files[0]).toMatchObject({ name: 'a.txt', path: '/Docs' }); + }); + + it('reports starring and unstarring a folder', async () => { + await openDocs(); + const toggled = vi.fn(); + fileHooks.onFileFavoriteToggle.register('test', toggled); + useFileStore.setState({ favorites: [] }); + + useFileStore.getState().toggleFavorite('/Docs/Target'); + useFileStore.getState().toggleFavorite('/Docs/Target'); + await flush(); + + expect(toggled.mock.calls.map(c => c[0])).toEqual([ + { file: expect.objectContaining({ id: 'target', name: 'Target', type: 'directory', path: '/Docs' }), favorite: true }, + { file: expect.objectContaining({ id: 'target' }), favorite: false }, + ]); + }); + + it('reports an undone rename', async () => { + await openDocs(); + const undone = vi.fn(); + fileHooks.onFileUndo.register('test', undone); + + await useFileStore.getState().renameResource('a.txt', 'c.txt'); + await useFileStore.getState().undoLastAction(); + await flush(); + + expect(undone).toHaveBeenCalledOnce(); + expect(undone).toHaveBeenCalledWith({ + action: { type: 'rename', entries: [{ id: 'a', from: { name: 'a.txt' }, to: { name: 'c.txt' } }] }, + }); + }); + + it('does not report an undo the server refused', async () => { + const client = await openDocs(); + await useFileStore.getState().renameResource('a.txt', 'c.txt'); + client.updateFileNode.mockRejectedValueOnce(new Error('nope')); + const undone = vi.fn(); + fileHooks.onFileUndo.register('test', undone); + + await expect(useFileStore.getState().undoLastAction()).rejects.toThrow(); + await flush(); + expect(undone).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 212b5e02e..952565f92 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -319,6 +319,21 @@ function destroyedViews(views: FileResourceView[], result: { destroyed?: string[ return views.filter(v => gone.has(v.id)); } +/** A starred folder as plugins see it; favourites are kept by path. */ +function favoriteFolderView(folders: FileResource[], path: string): FileResourceView { + const slash = path.lastIndexOf('/'); + const parent = slash > 0 ? path.slice(0, slash) : '/'; + const folder = folders.find(f => folderPath(folders, f.id) === path); + if (folder) return resourceToView(folder, parent); + return { id: '', name: path.slice(slash + 1), type: 'directory', size: 0, mimeType: '', path: parent, modified: '' }; +} + +function sameNameSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const n of a) if (!b.has(n)) return false; + return true; +} + /** A node that is not in any listing; plugins get at least its id. */ function unknownNodeView(id: string): FileResourceView { return { id, name: '', type: 'file', size: 0, mimeType: '', path: '', modified: '' }; @@ -607,8 +622,10 @@ export const useFileStore = create((set, get) => ({ }, navigate: async (parentId: string | null, name?: string) => { - const { client, pathStack } = get(); + const { client, pathStack, currentParentId: previousParentId, hasListed } = get(); if (!client) return; + // A refresh lists the same folder again; plugins only hear about a new one. + const opensFolder = !hasListed || parentId !== previousParentId; set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() }); @@ -654,6 +671,7 @@ export const useFileStore = create((set, get) => ({ } else { set({ resources, allFolders, isLoading: false, hasListed: true }); } + if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to list directory', @@ -1246,12 +1264,14 @@ export const useFileStore = create((set, get) => ({ }, toggleFavorite: (path: string) => { - const { favorites } = get(); - const next = favorites.includes(path) - ? favorites.filter(f => f !== path) - : [...favorites, path]; + const { favorites, allFolders } = get(); + const favorite = !favorites.includes(path); + const next = favorite ? [...favorites, path] : favorites.filter(f => f !== path); set({ favorites: next }); try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ } + if (fileHooks.onFileFavoriteToggle.size > 0) { + void fileHooks.onFileFavoriteToggle.emit({ file: favoriteFolderView(allFolders, path), favorite }); + } }, addRecentFile: (name: string, id: string) => { @@ -1387,6 +1407,12 @@ export const useFileStore = create((set, get) => ({ await client.updateFileNode(entry.id, entry.from); } set({ lastAction: null }); + void fileHooks.onFileUndo.emit({ + action: { + type: lastAction.type, + entries: lastAction.entries.map(e => ({ id: e.id, from: { ...e.from }, to: { ...e.to } })), + }, + }); await refresh(); }, @@ -1424,3 +1450,15 @@ export const useFileStore = create((set, get) => ({ } }, })); + +// Tell plugins when the selected files change, whichever action changed it +// (click, toggle, select all, clear, navigating away, after a delete or move). +// Selecting the same files again is not a change. +useFileStore.subscribe((state, prev) => { + if (state.selectedResources === prev.selectedResources) return; + if (fileHooks.onFileSelectionChange.size === 0) return; + const next = state.selectedResources ?? new Set(); + if (sameNameSet(next, prev.selectedResources ?? new Set())) return; + const files = state.resources.filter(r => next.has(r.name)).map(r => resourceToView(r, state.currentPath)); + void fileHooks.onFileSelectionChange.emit({ files }); +}); From 051e82b48799494a23dfd24c7a0932865972bc93 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:40:04 +0200 Subject: [PATCH 047/147] feat: tell plugins about layout changes, drag and drop, and sidebar apps --- contexts/drag-drop-context.tsx | 19 ++- hooks/__tests__/plugin-ui-hooks.test.tsx | 156 ++++++++++++++++++++++ hooks/use-mailbox-drop.ts | 7 +- hooks/use-sidebar-apps.ts | 10 ++ hooks/use-tag-drop.ts | 8 +- lib/__tests__/plugin-hook-catalog.test.ts | 4 +- lib/plugin-hook-catalog.ts | 6 +- stores/ui-store.ts | 44 ++++++ 8 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 hooks/__tests__/plugin-ui-hooks.test.tsx diff --git a/contexts/drag-drop-context.tsx b/contexts/drag-drop-context.tsx index 97c498e34..465b90f80 100644 --- a/contexts/drag-drop-context.tsx +++ b/contexts/drag-drop-context.tsx @@ -1,7 +1,8 @@ "use client"; -import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useRef, ReactNode } from "react"; import { Email } from "@/lib/jmap/types"; +import { dragDropHooks } from "@/lib/plugin-hooks"; interface DragDropState { isDragging: boolean; @@ -13,6 +14,8 @@ interface DragDropState { interface DragDropContextValue extends DragDropState { startDrag: (emails: Email[], sourceMailboxId: string) => void; endDrag: () => void; + /** A drop target accepted the dragged messages. */ + markDropped: () => void; } const initialState: DragDropState = { @@ -26,8 +29,13 @@ const DragDropContext = createContext(null); export function DragDropProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(initialState); + // The drag plugins were told about; endDrag runs more than once per drag + // (the drop target and the dragged row both call it). + const current = useRef<{ ids: string[]; dropped: boolean } | null>(null); const startDrag = useCallback((emails: Email[], sourceMailboxId: string) => { + current.current = { ids: emails.map((e) => e.id), dropped: false }; + void dragDropHooks.onDragStart.emit({ kind: "email", ids: current.current.ids }); setState({ isDragging: true, draggedEmails: emails, @@ -37,11 +45,18 @@ export function DragDropProvider({ children }: { children: ReactNode }) { }, []); const endDrag = useCallback(() => { + const drag = current.current; + current.current = null; + if (drag) void dragDropHooks.onDragEnd.emit({ kind: "email", ids: drag.ids, dropped: drag.dropped }); setState(initialState); }, []); + const markDropped = useCallback(() => { + if (current.current) current.current.dropped = true; + }, []); + return ( - + {children} ); diff --git a/hooks/__tests__/plugin-ui-hooks.test.tsx b/hooks/__tests__/plugin-ui-hooks.test.tsx new file mode 100644 index 000000000..60b7e7e33 --- /dev/null +++ b/hooks/__tests__/plugin-ui-hooks.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode, DragEvent } from 'react'; +import { clearAllHooks, dragDropHooks, sidebarAppHooks, uiHooks } from '@/lib/plugin-hooks'; +import { useUIStore } from '@/stores/ui-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { DragDropProvider, useDragDropContext } from '@/contexts/drag-drop-context'; +import { useMailboxDrop } from '../use-mailbox-drop'; +import { useTagDrop } from '../use-tag-drop'; +import { useSidebarApps } from '../use-sidebar-apps'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +function listen(bus: { register: (id: string, fn: (...a: unknown[]) => unknown) => unknown }) { + const fn = vi.fn(); + bus.register('test', fn); + return fn; +} + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +function dropEvent(ids: string[]): DragEvent { + return { + preventDefault() {}, stopPropagation() {}, + dataTransfer: { getData: () => JSON.stringify(ids) }, + } as unknown as DragEvent; +} + +const EMAIL = { id: 'm1', mailboxIds: { inbox: true }, keywords: {} } as unknown as Email; + +beforeEach(() => { + clearAllHooks(); + useUIStore.setState({ activeView: 'list', sidebarOpen: false, sidebarCollapsed: false, isMobile: true, isTablet: false, isDesktop: false }); +}); + +describe('layout hooks', () => { + it('reports view, sidebar and device changes', () => { + const view = listen(uiHooks.onViewChange); + const toggle = listen(uiHooks.onSidebarToggle); + const collapse = listen(uiHooks.onSidebarCollapse); + const device = listen(uiHooks.onDeviceTypeChange); + + useUIStore.getState().showEmailViewer(); + useUIStore.getState().toggleSidebar(); + useUIStore.getState().setSidebarCollapsed(true); + useUIStore.getState().setDeviceType(false, false, true); + useUIStore.getState().setDeviceType(false, false, true); + + expect(view).toHaveBeenCalledWith({ view: 'viewer' }); + expect(toggle).toHaveBeenCalledWith({ open: true }); + expect(collapse).toHaveBeenCalledWith({ collapsed: true }); + expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }]]); + }); + + it('reports Back on the phone', () => { + const back = listen(uiHooks.onMobileBack); + + useUIStore.getState().goBack(); + + expect(back).toHaveBeenCalledTimes(1); + }); + + it('reports a column once the resize ends, not during the drag', () => { + const resized = listen(uiHooks.onColumnResize); + const start = useUIStore.getState().sidebarWidth; + + useUIStore.getState().setSidebarWidth(start + 10); + useUIStore.getState().setSidebarWidth(start + 20); + expect(resized).not.toHaveBeenCalled(); + useUIStore.getState().persistColumnWidths(); + + expect(resized.mock.calls).toEqual([[{ column: 'sidebar', width: useUIStore.getState().sidebarWidth }]]); + }); +}); + +describe('drag and drop hooks', () => { + beforeEach(() => { + useAuthStore.setState({ client: {} as never }); + useEmailStore.setState({ + emails: [EMAIL], mailboxes: [], selectedEmailKeys: new Set(), + moveEmailsToMailbox: vi.fn().mockResolvedValue(undefined), + refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined), + setEmailKeywords: vi.fn().mockResolvedValue(undefined), + fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchTagCounts: vi.fn(), + clearSelection: vi.fn(), + } as never); + }); + + it('reports a drag of messages dropped on a folder', async () => { + const started = listen(dragDropHooks.onDragStart); + const ended = listen(dragDropHooks.onDragEnd); + const dropped = listen(dragDropHooks.onEmailDrop); + const target = { id: 'archive', myRights: { mayAddItems: true } } as unknown as Mailbox; + const { result } = renderHook( + () => ({ ctx: useDragDropContext(), drop: useMailboxDrop({ mailbox: target, onSuccess: () => {} }) }), + { wrapper }, + ); + + act(() => { result.current.ctx.startDrag([EMAIL], 'inbox'); }); + await act(async () => { await result.current.drop.dropHandlers.onDrop(dropEvent(['m1'])); }); + + expect(started).toHaveBeenCalledWith({ kind: 'email', ids: ['m1'] }); + expect(dropped).toHaveBeenCalledWith({ emailIds: ['m1'], targetMailboxId: 'archive' }); + expect(ended.mock.calls).toEqual([[{ kind: 'email', ids: ['m1'], dropped: true }]]); + }); + + it('reports a drag that ended without a drop', () => { + const ended = listen(dragDropHooks.onDragEnd); + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + + act(() => { result.current.startDrag([EMAIL], 'inbox'); }); + act(() => { result.current.endDrag(); }); + act(() => { result.current.endDrag(); }); + + expect(ended.mock.calls).toEqual([[{ kind: 'email', ids: ['m1'], dropped: false }]]); + }); + + it('reports messages dropped on a tag', async () => { + const tagged = listen(dragDropHooks.onTagDrop); + const { result } = renderHook( + () => ({ ctx: useDragDropContext(), drop: useTagDrop({ tagId: 'work', onSuccess: () => {} }) }), + { wrapper }, + ); + + act(() => { result.current.ctx.startDrag([EMAIL], 'inbox'); }); + await act(async () => { await result.current.drop.dropHandlers.onDrop(dropEvent(['m1'])); }); + + expect(tagged).toHaveBeenCalledWith({ emailIds: ['m1'], keyword: '$label:work' }); + }); +}); + +describe('sidebar app hooks', () => { + it('reports an app opening, and closing when the page goes away', () => { + const opened = listen(sidebarAppHooks.onSidebarAppOpen); + const closed = listen(sidebarAppHooks.onSidebarAppClose); + // An app picked just before a reload opens on mount. + sessionStorage.setItem('bulwark:pending-inline-app', JSON.stringify({ id: 'wiki', url: 'https://wiki.example.org', name: 'Wiki' })); + const { unmount } = renderHook(() => useSidebarApps()); + + expect(opened).toHaveBeenCalledWith({ appId: 'wiki' }); + expect(closed).not.toHaveBeenCalled(); + unmount(); + expect(closed).toHaveBeenCalledWith({ appId: 'wiki' }); + }); + + it('reports an app the user closes', () => { + const closed = listen(sidebarAppHooks.onSidebarAppClose); + sessionStorage.setItem('bulwark:pending-inline-app', JSON.stringify({ id: 'wiki', url: 'https://wiki.example.org', name: 'Wiki' })); + const { result } = renderHook(() => useSidebarApps()); + + act(() => { result.current.closeInlineApp(); }); + + expect(closed.mock.calls).toEqual([[{ appId: 'wiki' }]]); + }); +}); diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 61c6792f8..ebc443e15 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; +import { dragDropHooks } from "@/lib/plugin-hooks"; import { toast } from "@/stores/toast-store"; import { getMailboxPath } from "@/lib/utils"; @@ -67,7 +68,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); - const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); + const { isDragging, sourceMailboxId, draggedEmails, endDrag, markDropped } = useDragDropContext(); // Determine if this is a valid drop target const isValidTarget = useCallback(() => { @@ -129,6 +130,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } const emailIds: string[] = JSON.parse(emailIdsJson); + markDropped(); // Group dragged emails by source account. In single-account flows this // collapses to one bucket; in unified view or the Pro multi-account @@ -183,6 +185,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // Single-account or same-account-shared move: bulk JMAP request. await moveEmailsToMailbox(client, emailIds, mailbox.id); } + void dragDropHooks.onEmailDrop.emit({ emailIds, targetMailboxId: mailbox.id }); // Clear selection if any selected emails were moved. The selection is // keyed by owning account, so compare the dragged emails, not their ids. @@ -224,7 +227,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, markDropped, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); diff --git a/hooks/use-sidebar-apps.ts b/hooks/use-sidebar-apps.ts index b198a8f7a..5cdc9e816 100644 --- a/hooks/use-sidebar-apps.ts +++ b/hooks/use-sidebar-apps.ts @@ -3,6 +3,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; import { appUrlToFrameOrigin, inlineAppFrameOrigins } from '@/lib/security/app-frame-origins'; import { readAppFrameOrigins, writeAppFrameOrigins } from '@/lib/security/app-frame-origins-client'; +import { sidebarAppHooks } from '@/lib/plugin-hooks'; export interface InlineAppState { id: string; @@ -48,6 +49,15 @@ export function useSidebarApps() { // opening one must not trigger the reload below. const managedOrigins = useMemo(() => inlineAppFrameOrigins(managedApps), [managedApps]); + // Plugins hear when an app opens, and when it closes: closed by the user, + // replaced by another app, or gone because the user left the page. + const inlineAppId = inlineApp?.id ?? null; + useEffect(() => { + if (!inlineAppId) return; + void sidebarAppHooks.onSidebarAppOpen.emit({ appId: inlineAppId }); + return () => { void sidebarAppHooks.onSidebarAppClose.emit({ appId: inlineAppId }); }; + }, [inlineAppId]); + const openInlineApp = useCallback((app: InlineAppState) => { setInlineApp(app); setLoadedApps((prev) => (prev.some((a) => a.id === app.id) ? prev : [...prev, app])); diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index cb99edb8b..761f57617 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -4,6 +4,7 @@ import { useCallback, useState, DragEvent } from "react"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; +import { dragDropHooks } from "@/lib/plugin-hooks"; interface UseTagDropOptions { @@ -27,7 +28,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(); - const { isDragging, endDrag } = useDragDropContext(); + const { isDragging, endDrag, markDropped } = useDragDropContext(); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); @@ -71,6 +72,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us } const emailIds: string[] = JSON.parse(emailIdsJson); + markDropped(); for (const emailId of emailIds) { // Read fresh state to avoid stale closures @@ -87,6 +89,8 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us await emailState.setEmailKeywords(client, emailId, keywords); } + void dragDropHooks.onTagDrop.emit({ emailIds, keyword: `$label:${tagId}` }); + // Refresh the email list await fetchEmails(client, selectedMailbox); @@ -100,7 +104,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us } finally { endDrag(); } - }, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, onSuccess, onError]); + }, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, markDropped, onSuccess, onError]); return { dropHandlers: { diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index 5d5dc3173..a26f9e562 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -31,7 +31,7 @@ function sourceFiles(dir: string): string[] { return out; } -const sources = ['app', 'components', 'hooks', 'lib', 'stores'].flatMap((d) => sourceFiles(path.join(root, d))) +const sources = ['app', 'components', 'contexts', 'hooks', 'lib', 'stores'].flatMap((d) => sourceFiles(path.join(root, d))) .map((f) => fs.readFileSync(f, 'utf8')) .join('\n'); @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'security', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'security', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index d8d156a91..57e05fdc9 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -284,14 +284,14 @@ export const HOOK_CATALOG = { onInterval: H('app', 'observer', 'app:lifecycle', '{ now }', 'once a minute while the app is open'), // ─── Layout ──────────────────────────────────────────────── - onViewChange: H('ui', 'observer', 'ui:observe', '{ view }', 'when the main view changes (list, message, composer)'), + onViewChange: H('ui', 'observer', 'ui:observe', '{ view: "sidebar" | "list" | "viewer" }', 'when the phone layout switches between folders, message list and message'), onSidebarToggle: H('ui', 'observer', 'ui:observe', '{ open }', 'when the sidebar is opened or closed'), onSidebarCollapse: H('ui', 'observer', 'ui:observe', '{ collapsed }', 'when the sidebar is collapsed or expanded'), onDeviceTypeChange: H('ui', 'observer', 'ui:observe', '{ deviceType: "mobile" | "tablet" | "desktop" }', 'when the layout switches between phone, tablet and desktop'), - onColumnResize: H('ui', 'observer', 'ui:observe', '{ column, width }', 'after a column was resized'), + onColumnResize: H('ui', 'observer', 'ui:observe', '{ column: "sidebar" | "email-list" | "email-list-height", width }', 'after a column was resized or reset'), onMobileBack: H('ui', 'observer', 'ui:observe', '{}', 'when Back is used on the phone layout'), onTextSelectionChange: H('ui', 'observer', 'email:read', 'SelectionContext', 'when the user selects text (debounced)'), - onDragStart: H('ui', 'observer', 'email:read', '{ kind, ids }', 'when messages or tags start being dragged'), + onDragStart: H('ui', 'observer', 'email:read', '{ kind: "email", ids }', 'when messages start being dragged'), onDragEnd: H('ui', 'observer', 'email:read', '{ kind, ids, dropped }', 'when a drag ends'), onEmailDrop: H('ui', 'observer', 'email:read', '{ emailIds, targetMailboxId }', 'after messages were dropped on a folder'), onTagDrop: H('ui', 'observer', 'email:read', '{ emailIds, keyword }', 'after a tag was dropped on messages'), diff --git a/stores/ui-store.ts b/stores/ui-store.ts index 12b48a13f..fb0882a4b 100644 --- a/stores/ui-store.ts +++ b/stores/ui-store.ts @@ -1,6 +1,7 @@ "use client"; import { create } from "zustand"; +import { uiHooks } from "@/lib/plugin-hooks"; export type ActiveView = "sidebar" | "list" | "viewer"; @@ -97,6 +98,7 @@ export const useUIStore = create((set, get) => ({ resetSidebarWidth: () => { set({ sidebarWidth: SIDEBAR_DEFAULT }); + reportColumnWidths(get()); const { emailListWidth, emailListHeight } = get(); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth, emailListHeight })); @@ -105,6 +107,7 @@ export const useUIStore = create((set, get) => ({ resetEmailListWidth: () => { set({ emailListWidth: EMAIL_LIST_DEFAULT }); + reportColumnWidths(get()); const { sidebarWidth, emailListHeight } = get(); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT, emailListHeight })); @@ -113,6 +116,7 @@ export const useUIStore = create((set, get) => ({ resetEmailListHeight: () => { set({ emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT }); + reportColumnWidths(get()); const { sidebarWidth, emailListWidth } = get(); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT })); @@ -121,6 +125,7 @@ export const useUIStore = create((set, get) => ({ persistColumnWidths: () => { const { sidebarWidth, emailListWidth, emailListHeight } = get(); + reportColumnWidths({ sidebarWidth, emailListWidth, emailListHeight }); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight })); } catch { /* localStorage may be unavailable */ } @@ -147,6 +152,7 @@ export const useUIStore = create((set, get) => ({ goBack: () => { const { activeView, isMobile } = get(); if (!isMobile) return; + void uiHooks.onMobileBack.emit({}); if (activeView === "viewer") { set({ activeView: "list" }); @@ -155,3 +161,41 @@ export const useUIStore = create((set, get) => ({ } }, })); + +type ColumnWidths = Pick; + +const COLUMN_NAMES: Record = { + sidebarWidth: "sidebar", + emailListWidth: "email-list", + emailListHeight: "email-list-height", +}; + +let reportedWidths: ColumnWidths | null = null; + +// Called when a resize ends (widths are saved then), not on every pixel of +// the drag. Reports only the columns that changed since the last report. +function reportColumnWidths(widths: ColumnWidths): void { + const before = reportedWidths; + reportedWidths = { sidebarWidth: widths.sidebarWidth, emailListWidth: widths.emailListWidth, emailListHeight: widths.emailListHeight }; + if (!before) return; + for (const key of Object.keys(COLUMN_NAMES) as (keyof ColumnWidths)[]) { + if (widths[key] !== before[key]) { + void uiHooks.onColumnResize.emit({ column: COLUMN_NAMES[key], width: widths[key] }); + } + } +} + +function deviceType(s: Pick): "mobile" | "tablet" | "desktop" { + return s.isMobile ? "mobile" : s.isTablet ? "tablet" : "desktop"; +} + +// Plugins hear about layout changes, whichever control or effect made them. +useUIStore.subscribe((state, prev) => { + if (!reportedWidths) { + reportedWidths = { sidebarWidth: prev.sidebarWidth, emailListWidth: prev.emailListWidth, emailListHeight: prev.emailListHeight }; + } + if (state.activeView !== prev.activeView) void uiHooks.onViewChange.emit({ view: state.activeView }); + if (state.sidebarOpen !== prev.sidebarOpen) void uiHooks.onSidebarToggle.emit({ open: state.sidebarOpen }); + if (state.sidebarCollapsed !== prev.sidebarCollapsed) void uiHooks.onSidebarCollapse.emit({ collapsed: state.sidebarCollapsed }); + if (deviceType(state) !== deviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: deviceType(state) }); +}); From 89452871fbf3dd8945e1560af2cd86611e1c996e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:40:19 +0200 Subject: [PATCH 048/147] feat: tell plugins when an event reminder fires and when it is dismissed --- components/ui/toast.tsx | 20 +++- .../use-calendar-alerts-hooks.test.tsx | 95 +++++++++++++++++++ hooks/use-calendar-alerts.ts | 11 +++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 hooks/__tests__/use-calendar-alerts-hooks.test.tsx diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 28c7dbd91..f385face2 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -19,6 +19,11 @@ export interface Toast { message?: string; duration?: number; onClick?: () => void; + /** + * Called when the user closes the toast: the close button, a click on the + * toast or on one of its actions. Not called when it times out. + */ + onDismiss?: () => void; icon?: React.ReactNode; action?: ToastAction; secondaryAction?: ToastAction; @@ -90,6 +95,15 @@ export function ToastItem({ toast, onClose }: ToastProps) { setTimeout(() => onClose(toast.id), 280); }, [onClose, toast.id]); + const dismissByUser = useCallback(() => { + try { + toast.onDismiss?.(); + } catch { + // A failing listener must not keep the toast on screen. + } + dismiss(); + }, [toast, dismiss]); + useEffect(() => { if (!toast.duration || toast.duration <= 0 || isProgress) return; @@ -112,7 +126,7 @@ export function ToastItem({ toast, onClose }: ToastProps) { e.stopPropagation(); try { action.onClick(); - dismiss(); + dismissByUser(); } catch { // Don't close toast on error so user can retry } @@ -141,7 +155,7 @@ export function ToastItem({ toast, onClose }: ToastProps) { onClick={() => { if (toast.onClick && !toast.action) { toast.onClick(); - dismiss(); + dismissByUser(); } }} > @@ -180,7 +194,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
+
+ + +
+ +
+
+ ); +} diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index f58bb1b44..6f5a49b55 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -14,6 +14,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { PluginPermissionList } from '@/components/plugins/plugin-permission-list'; import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; import { effectiveSettings } from '@/lib/plugin-settings'; +import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; const STATUS_COLORS: Record = { installed: 'bg-muted text-muted-foreground', @@ -314,6 +315,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
)} + {plugin.credentials && Object.values(plugin.credentials).some((c) => c.scope === 'user') && ( + + )} + {!isManaged && (
+
+ ); + })} +
+ ); +} + // ─── Auto-generated Setting Field ──────────────────────────── interface PluginSettingFieldProps { diff --git a/lib/plugin-sandbox/connect-dialog.ts b/lib/plugin-sandbox/connect-dialog.ts new file mode 100644 index 000000000..18c86d184 --- /dev/null +++ b/lib/plugin-sandbox/connect-dialog.ts @@ -0,0 +1,148 @@ +// Host-side "Connect account" requests. A plugin asks to connect one of the +// user-scope credentials its manifest declares (api.proxy.connect); the host +// shows its own dialog naming the plugin and the server, the user types the +// password there, and it goes straight to /api/plugin-secrets. The plugin +// only learns whether the account is connected. + +import type { UserCredentialSpec } from '@/lib/plugin-secrets/credentials'; +import { apiFetch } from '../browser-navigation'; + +export interface ConnectRequest { + id: string; + pluginId: string; + pluginName: string; + credentialId: string; + spec: UserCredentialSpec; + /** + * `password`: the user types a password or key here. + * `signin`: only the server is approved here; the plugin then runs a + * sign-in flow whose answer the proxy captures (Nextcloud Login Flow v2). + */ + mode: 'password' | 'signin'; + /** Server address to suggest when the manifest leaves it to the user. */ + suggestedOrigin?: string; + suggestedUsername?: string; + resolve: (result: ConnectResult) => void; +} + +export type ConnectResult = { connected: boolean; origin?: string }; + +let current: ConnectRequest | null = null; +const listeners = new Set<() => void>(); + +function notify(): void { + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribeConnect(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function currentConnect(): ConnectRequest | null { + return current; +} + +export class ConnectBusyError extends Error { + constructor() { + super('Another account connection is already open'); + } +} + +/** Ask the user to connect an account. One request at a time, app-wide. */ +export function requestConnect(input: Omit): Promise { + if (current) return Promise.reject(new ConnectBusyError()); + return new Promise((resolve) => { + current = { + ...input, + id: Math.random().toString(36).slice(2), + resolve: (result) => { + current = null; + notify(); + resolve(result); + }, + }; + notify(); + }); +} + +/** Close an open request of this plugin (it was turned off or removed). */ +export function cancelConnectFor(pluginId: string): void { + if (current?.pluginId === pluginId) current.resolve({ connected: false }); +} + +export interface SaveCredentialInput { + pluginId: string; + credentialId: string; + label: string; + origin: string; + inject: UserCredentialSpec['inject']; + username?: string; + secret?: string; +} + +/** POST to /api/plugin-secrets. Returns an error message, or null. */ +export async function saveCredential(input: SaveCredentialInput): Promise { + const res = await apiFetch('/api/plugin-secrets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (res.ok) return null; + try { + const body = await res.json() as { error?: string }; + return body.error || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +/** Remove a plugin's stored credentials (all of them without credentialId). */ +export async function forgetCredentials(pluginId: string, credentialId?: string): Promise { + const query = new URLSearchParams({ plugin: pluginId, ...(credentialId ? { credential: credentialId } : {}) }); + try { + const res = await apiFetch(`/api/plugin-secrets?${query}`, { method: 'DELETE' }); + return res.ok; + } catch { + return false; + } +} + +export interface ConnectedAccount { + pluginId: string; + credentialId: string; + label: string; + origin: string; + username?: string; + connected: boolean; + updatedAt: string; +} + +export async function listConnectedAccounts(pluginId?: string): Promise { + const query = pluginId ? `?plugin=${encodeURIComponent(pluginId)}` : ''; + try { + const res = await apiFetch(`/api/plugin-secrets${query}`); + if (!res.ok) return []; + const body = await res.json() as { credentials?: ConnectedAccount[] }; + return Array.isArray(body.credentials) ? body.credentials : []; + } catch { + return []; + } +} + +/** Normalise what the user typed into an origin, adding https:// if missing. */ +export function originFromInput(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const url = new URL(withScheme); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + if (url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} diff --git a/locales/en/common.json b/locales/en/common.json index 942d372a4..736fec31e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "This plugin was installed before approvals were required. Remove it and install it again to send it for approval.", "error_approval_denied": "Your administrator did not approve this plugin.", "error_approval_pending": "Waiting for your administrator to approve this plugin.", - "error_auto_disabled": "Turned off because it kept failing. Turn it on again to retry." + "error_auto_disabled": "Turned off because it kept failing. Turn it on again to retry.", + "accounts_heading": "Connected accounts", + "account_on": "{label} on {host}", + "account_waiting": "{label} on {host} (sign-in not finished)", + "disconnect": "Disconnect", + "disconnect_failed": "Could not disconnect {label}" }, "consent": { "title": "Allow {name} to do this?", @@ -4255,6 +4260,22 @@ "turn_off": "Turn off {name}", "password_note": "This form comes from the plugin {name}, not from the webmail. Only enter a password if you trust this plugin.", "toast_auto_disabled": "{name} was turned off because it kept failing." + }, + "connect": { + "title": "Connect {label}", + "intro_password": "{name} wants to use your {label} account. Bulwark keeps the password on its server and only sends it to {host}. The plugin never sees it.", + "intro_password_any": "{name} wants to use your {label} account. Bulwark keeps the password on its server and only sends it to the server you enter here. The plugin never sees it.", + "intro_signin": "{name} wants to sign in to your {label} account. Enter the server here, then sign in on its own page. Bulwark keeps the access it gets on its server; the plugin never sees it.", + "server": "Server address", + "server_hint": "cloud.example.com", + "username": "User name", + "secret": "Password or key", + "connect": "Connect", + "continue": "Continue", + "cancel": "Cancel", + "saving": "Saving…", + "error_server": "Enter the server's address, for example https://cloud.example.com", + "error_save": "Could not save: {error}" } }, "global_search": { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index d07463f90..94f47c135 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -1,6 +1,7 @@ // Plugin store - manages installed plugins and lifecycle. Slot registrations // are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store. +import { cancelConnectFor, forgetCredentials } from '@/lib/plugin-sandbox/connect-dialog'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types'; @@ -346,6 +347,9 @@ function removePluginData(id: string): void { removeAllPluginHooks(id); void pluginStorage.deleteCode(id).catch(() => {}); void pluginStorage.deletePreview(id).catch(() => {}); + // Accounts the user connected for it live on the server (this mail account's). + cancelConnectFor(id); + void forgetCredentials(id); if (typeof window !== 'undefined') { const prefix = `plugin:${id}:`; const keysToRemove: string[] = []; From 08e2d65944639d51b76616cb9444080f9a921545 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:16:31 +0200 Subject: [PATCH 072/147] feat: let untrusted plugins save attachments and message sources of a given message --- lib/__tests__/plugin-host-mail-bytes.test.ts | 102 +++++++++++++++++++ lib/plugin-sandbox/host-api-input.ts | 8 ++ lib/plugin-sandbox/host-api-mail.ts | 68 ++++++++++++- lib/plugin-sandbox/host-api.ts | 22 ++-- lib/plugin-sandbox/runtime.tsx | 12 +++ lib/plugin-types.ts | 4 +- locales/en/common.json | 2 +- 7 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 lib/__tests__/plugin-host-mail-bytes.test.ts diff --git a/lib/__tests__/plugin-host-mail-bytes.test.ts b/lib/__tests__/plugin-host-mail-bytes.test.ts new file mode 100644 index 000000000..dee905e22 --- /dev/null +++ b/lib/__tests__/plugin-host-mail-bytes.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Email } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { dispatchApiCall, MAX_PLUGIN_FETCH_BYTES } from '../plugin-sandbox/host-api'; + +function plugin(perms: string[]): InstalledPlugin { + return { + id: 'archiver', name: 'Archiver', version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const ARCHIVER = plugin(['email:blob-read']); +// Untrusted tier: no { privileged: true } here, which is the point. +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); + +const invoice = { partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 3 }; +const huge = { partId: '3', blobId: 'blob-big', name: 'big.iso', type: 'application/octet-stream', size: MAX_PLUGIN_FETCH_BYTES + 1 }; + +const message = { + id: 'm1', threadId: 't1', blobId: 'blob-raw', size: 4, mailboxIds: { inbox: true }, keywords: {}, + subject: 'Invoice', receivedAt: '2026-05-01T10:00:00Z', attachments: [invoice, huge], +} as unknown as Email; + +let client: Record>; + +beforeEach(() => { + client = { + getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)), + fetchBlobArrayBuffer: vi.fn(async (blobId: string) => { + if (blobId === 'blob-raw') return new TextEncoder().encode('From').buffer; + return new Uint8Array([1, 2, 3]).buffer; + }), + }; + useAuthStore.setState({ client: client as unknown as IJMAPClient }); + useEmailStore.setState({ selectedEmail: null }); +}); + +afterEach(() => { + useAuthStore.setState({ client: null }); +}); + +describe('api.email.getAttachment', () => { + it('returns the bytes of an attachment of that message, to an untrusted plugin', async () => { + const result = await call(ARCHIVER, 'email.getAttachment', 'm1', 'blob-pdf') as { + name: string; type: string; size: number; data: ArrayBuffer; + }; + expect(result.name).toBe('invoice.pdf'); + expect(result.type).toBe('application/pdf'); + expect(result.size).toBe(3); + expect(result.data).toBeInstanceOf(ArrayBuffer); + expect([...new Uint8Array(result.data)]).toEqual([1, 2, 3]); + expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-pdf', 'invoice.pdf', 'application/pdf'); + }); + + it('refuses a blob that is not an attachment of that message', async () => { + await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 'blob-raw')).rejects.toThrow(/not an attachment of this message/); + await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 'someone-elses-blob')).rejects.toThrow(/not an attachment/); + await expect(call(ARCHIVER, 'email.getAttachment', 'missing', 'blob-pdf')).rejects.toThrow(/no message with id "missing"/); + expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); + }); + + it('refuses attachments over 25 MB before downloading them', async () => { + await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 'blob-big')).rejects.toThrow(/larger than 26214400 bytes/); + expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); + }); + + it('needs email:blob-read, and string ids', async () => { + await expect(call(plugin(['email:read']), 'email.getAttachment', 'm1', 'blob-pdf')).rejects.toThrow(/lacks permission "email:blob-read"/); + await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 7)).rejects.toThrow(/blobId must be a string/); + expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); + }); +}); + +describe('api.email.getRaw', () => { + it('returns the message source from its own blob', async () => { + const raw = await call(ARCHIVER, 'email.getRaw', 'm1') as ArrayBuffer; + expect(new TextDecoder().decode(raw)).toBe('From'); + expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-raw', 'message.eml', 'message/rfc822'); + }); + + it('refuses big messages, unknown ones and missing permission', async () => { + client.getEmail.mockResolvedValueOnce({ ...message, size: MAX_PLUGIN_FETCH_BYTES + 1 }); + await expect(call(ARCHIVER, 'email.getRaw', 'm1')).rejects.toThrow(/larger than/); + await expect(call(ARCHIVER, 'email.getRaw', 'missing')).rejects.toThrow(/no message/); + await expect(call(plugin([]), 'email.getRaw', 'm1')).rejects.toThrow(/lacks permission "email:blob-read"/); + expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); + }); + + it('leaves arbitrary blob reads to the privileged tier', async () => { + await expect(call(ARCHIVER, 'jmap.fetchBlob', 'blob-raw')).rejects.toThrow(/requires the privileged plugin tier/); + }); +}); diff --git a/lib/plugin-sandbox/host-api-input.ts b/lib/plugin-sandbox/host-api-input.ts index 16957673a..d53603f2f 100644 --- a/lib/plugin-sandbox/host-api-input.ts +++ b/lib/plugin-sandbox/host-api-input.ts @@ -7,6 +7,14 @@ import { useAuthStore } from '@/stores/auth-store'; import type { IJMAPClient } from '../jmap/client-interface'; import { isValidEmail } from '../validation'; +/** + * Largest body the host hands a plugin in one answer: an http.fetch + * response, an attachment or a message source. It is copied across + * postMessage, so an unbounded one could take the tab down. Bigger transfers + * go through the server proxy. + */ +export const MAX_PLUGIN_FETCH_BYTES = 25 * 1024 * 1024; + /** Longest id (event, task, template, email, blob, ...) a plugin may pass. */ export const MAX_PLUGIN_ID_LENGTH = 256; diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts index 6ea809f8c..bbd60a135 100644 --- a/lib/plugin-sandbox/host-api-mail.ts +++ b/lib/plugin-sandbox/host-api-mail.ts @@ -1,6 +1,7 @@ -// Host side of reading mail for plugins: api.email.get / list and -// api.mailboxes.list. Reads go to the server through the JMAP client and -// never change what the user has open. +// Host side of reading mail for plugins: api.email.get / list, +// api.mailboxes.list, and the bytes of an attachment or a whole message +// (api.email.getAttachment / getRaw). Reads go to the server through the +// JMAP client and never change what the user has open. // // Messages are read from the signed-in account itself; mail in folders shared // from other accounts is not reached by id here. @@ -10,7 +11,9 @@ import type { Email, Mailbox } from '../jmap/types'; import type { IJMAPClient } from '../jmap/client-interface'; import { useEmailStore } from '@/stores/email-store'; import { emailSummary, emailToReadView } from '../plugin-projection'; -import { readId, readInteger, readObject, sessionClient } from './host-api-input'; +import { + MAX_PLUGIN_FETCH_BYTES, readId, readInteger, readObject, sessionClient, +} from './host-api-input'; /** Most messages one api.email.list call returns. */ export const MAX_PLUGIN_EMAIL_PAGE = 100; @@ -74,3 +77,60 @@ export async function doEmailList(options: unknown): Promise { const { emails } = await client.getEmails(jmapMailboxId, accountId, limit, position); return emails.map(emailSummary); } + +// ─── Bytes ──────────────────────────────────────────────────── + +/** Every attachment part of a message, nested ones included. */ +function attachmentParts(email: Email): NonNullable { + return email.attachments ?? []; +} + +function tooBig(method: string): Error { + return new Error(`${method}: it is larger than ${MAX_PLUGIN_FETCH_BYTES} bytes`); +} + +export interface PluginAttachmentData { + name: string; + type: string; + size: number; + data: ArrayBuffer; +} + +/** + * The bytes of one attachment of one message. The blob has to be an + * attachment of that message: a plugin can't use this to read arbitrary + * blobs (that stays jmap.fetchBlob, privileged tier only). + */ +export async function doEmailGetAttachment(emailIdArg: unknown, blobIdArg: unknown): Promise { + const method = 'email.getAttachment'; + const emailId = readId(emailIdArg, method, 'emailId'); + const blobId = readId(blobIdArg, method, 'blobId'); + const client = sessionClient(method); + const email = await loadEmail(client, emailId); + if (!email) throw new Error(`${method}: no message with id "${emailId}"`); + const part = attachmentParts(email).find((a) => a.blobId === blobId); + if (!part) throw new Error(`${method}: that blob is not an attachment of this message`); + if (part.size > MAX_PLUGIN_FETCH_BYTES) throw tooBig(method); + const data = await client.fetchBlobArrayBuffer(blobId, part.name || undefined, part.type || undefined); + if (data.byteLength > MAX_PLUGIN_FETCH_BYTES) throw tooBig(method); + return { + name: part.name || 'attachment', + type: part.type || 'application/octet-stream', + size: data.byteLength, + data, + }; +} + +/** The message as the server stores it (RFC 5322), e.g. to save it as a .eml file. */ +export async function doEmailGetRaw(emailIdArg: unknown): Promise { + const method = 'email.getRaw'; + const emailId = readId(emailIdArg, method, 'emailId'); + const client = sessionClient(method); + const email = await loadEmail(client, emailId); + if (!email) throw new Error(`${method}: no message with id "${emailId}"`); + if (!email.blobId) throw new Error(`${method}: the server did not say where the message source is`); + if (email.size > MAX_PLUGIN_FETCH_BYTES) throw tooBig(method); + const data = await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822'); + if (data.byteLength > MAX_PLUGIN_FETCH_BYTES) throw tooBig(method); + return data; +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index ab7ac301b..07ace0745 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -41,7 +41,10 @@ import { doIdentitiesUpdate, doTemplatesCreate, doTemplatesDelete, doTemplatesGet, doTemplatesList, doTemplatesUpdate, doVacationGet, doVacationSet, } from './host-api-settings'; -import { doEmailGet, doEmailList, doMailboxesList } from './host-api-mail'; +import { + doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doMailboxesList, +} from './host-api-mail'; +import { MAX_PLUGIN_FETCH_BYTES } from './host-api-input'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -214,6 +217,11 @@ export const PERM_PER_METHOD: Readonly> = { 'email.get': 'email:read', 'email.list': 'email:read', 'mailboxes.list': 'email:read', + // The bytes of one attachment of a given message, or of the message + // itself. Unlike jmap.fetchBlob these only reach blobs of a message the + // plugin names, so the untrusted tier may call them. + 'email.getAttachment': 'email:blob-read', + 'email.getRaw': 'email:blob-read', }; // ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ────── @@ -550,12 +558,10 @@ interface PluginFetchInit { body?: string | ArrayBuffer | ArrayBufferView | null; } -/** - * Largest response body http.fetch hands to a plugin. The body is copied - * across postMessage twice (text and bytes), so an unbounded one could take - * the tab down. Bigger transfers go through the server proxy. - */ -export const MAX_PLUGIN_FETCH_BYTES = 25 * 1024 * 1024; +// The http.fetch body is copied across postMessage twice (text and bytes); +// MAX_PLUGIN_FETCH_BYTES (host-api-input.ts) caps it like every other body +// the host hands a plugin. +export { MAX_PLUGIN_FETCH_BYTES }; async function readCapped(res: Response, limit: number): Promise { const declared = Number(res.headers.get('content-length')); @@ -1903,6 +1909,8 @@ export async function dispatchApiCall( case 'email.get': return doEmailGet(args[0]); case 'email.list': return doEmailList(args[0]); case 'mailboxes.list': return doMailboxesList(); + case 'email.getAttachment': return doEmailGetAttachment(args[0], args[1]); + case 'email.getRaw': return doEmailGetRaw(args[0]); default: throw new Error(`Unhandled method "${method}"`); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 7205ff6da..24ad08211 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -442,6 +442,18 @@ function buildPluginApi(manifest: PluginManifest) { */ list: (options?: { mailboxId?: string; limit?: number; position?: number }) => callApi('email.list', [options]) as Promise, + /** + * The bytes of one attachment of a message (email:blob-read, at most + * 25 MB). `blobId` must be one of that message's attachments, e.g. + * from an `attachment-actions` slot or onAttachmentDownload. + */ + getAttachment: (emailId: string, blobId: string) => + callApi('email.getAttachment', [emailId, blobId], NETWORK_API_TIMEOUT_MS) as Promise<{ + name: string; type: string; size: number; data: ArrayBuffer; + }>, + /** The full RFC 5322 source of a message, to save as .eml (email:blob-read, at most 25 MB). */ + getRaw: (emailId: string) => + callApi('email.getRaw', [emailId], NETWORK_API_TIMEOUT_MS) as Promise, setKeyword: (emailId: string, keyword: string, accountId?: string) => callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise, removeKeyword: (emailId: string, keyword: string, accountId?: string) => diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index c21df5cb6..213b2f2e0 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1191,7 +1191,9 @@ export const ALL_PERMISSIONS = [ // Submit a fully-formed raw RFC822 message via JMAP (used after a plugin // signs/encrypts an outgoing message itself). 'email:raw-send', - // Fetch a message blob's raw bytes by blobId (for decrypt/verify). + // Read the bytes of a message's attachments and its full source + // (email.getAttachment / email.getRaw), and of files the user attaches. + // Arbitrary blob ids (jmap.fetchBlob) stay privileged-tier only. 'email:blob-read', // Upload a file to server (for encrypt). 'email:blob-write', diff --git a/locales/en/common.json b/locales/en/common.json index f4a543f11..591e34c8a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4054,7 +4054,7 @@ }, "email_blob_read": { "title": "Read the raw content of messages", - "body": "Read the original data of your messages and attachments, and of every file you attach before it is sent." + "body": "Read attachments and the full source of messages you act on, and every file you attach before it is sent." }, "email_blob_write": { "title": "Replace attachments before sending", From 31205e44d821da8272a1e0ac5f76dc6f2daf608e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:17:51 +0200 Subject: [PATCH 073/147] fix: stop cutting off uploads over 10 MB that pass through the server, such as office documents saved from the editor --- lib/plugin-secrets/proxy.ts | 4 ++-- next.config.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/plugin-secrets/proxy.ts b/lib/plugin-secrets/proxy.ts index 043e5c916..46dab8124 100644 --- a/lib/plugin-secrets/proxy.ts +++ b/lib/plugin-secrets/proxy.ts @@ -12,8 +12,8 @@ import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard'; import { authHeaderFor, credentialOrigin, privateProxyOrigins, type CredentialInject, type CredentialSpec } from './credentials'; import { getUserCredential, setUserCredentialSecret, userKey, type VaultUser } from './vault'; -/** Largest request body the proxy forwards (one upload chunk). */ -export const MAX_PROXY_REQUEST_BYTES = 512 * 1024 * 1024; +/** Largest request body the proxy forwards (one upload chunk); see proxyClientMaxBodySize in next.config.ts. */ +export const MAX_PROXY_REQUEST_BYTES = 100 * 1024 * 1024; /** Largest response body it passes back (a download). */ export const MAX_PROXY_RESPONSE_BYTES = 2 * 1024 * 1024 * 1024; /** Largest response it reads to capture a secret from. */ diff --git a/next.config.ts b/next.config.ts index 43ebe02b0..65f3e6c1b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -59,6 +59,14 @@ const nextConfig: NextConfig = { // it from node_modules at runtime instead of trying to bundle it. Used by // PLUGIN_DEV_DIR's on-the-fly bundler. serverExternalPackages: ["esbuild"], + experimental: { + // proxy.ts runs for every request, /api included, and Next buffers the + // body for it up to this size and silently cuts off the rest. 10 MB (the + // default) truncated WOPI saves and WebDAV uploads of bigger files, and + // plugin uploads through /api/plugin-proxy. Keep MAX_PROXY_REQUEST_BYTES + // in lib/plugin-secrets/proxy.ts in step with it. + proxyClientMaxBodySize: "100mb", + }, // Response headers are the host's job in a static export (see out/_headers). ...(isLite ? {} From b2b80d175589a3551a1a39d77be0e20e884e2226 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:19:43 +0200 Subject: [PATCH 074/147] feat: translate the plugin account dialog into every language --- locales/ar/common.json | 23 ++++++++++++++++++++++- locales/ca/common.json | 23 ++++++++++++++++++++++- locales/cs/common.json | 23 ++++++++++++++++++++++- locales/da/common.json | 23 ++++++++++++++++++++++- locales/de/common.json | 23 ++++++++++++++++++++++- locales/es/common.json | 23 ++++++++++++++++++++++- locales/fa/common.json | 23 ++++++++++++++++++++++- locales/fr/common.json | 23 ++++++++++++++++++++++- locales/he/common.json | 23 ++++++++++++++++++++++- locales/hu/common.json | 23 ++++++++++++++++++++++- locales/it/common.json | 23 ++++++++++++++++++++++- locales/ja/common.json | 23 ++++++++++++++++++++++- locales/ko/common.json | 23 ++++++++++++++++++++++- locales/lv/common.json | 23 ++++++++++++++++++++++- locales/mn/common.json | 23 ++++++++++++++++++++++- locales/nb/common.json | 23 ++++++++++++++++++++++- locales/nl/common.json | 23 ++++++++++++++++++++++- locales/pl/common.json | 23 ++++++++++++++++++++++- locales/pt/common.json | 23 ++++++++++++++++++++++- locales/ro/common.json | 23 ++++++++++++++++++++++- locales/ru/common.json | 23 ++++++++++++++++++++++- locales/sk/common.json | 23 ++++++++++++++++++++++- locales/tr/common.json | 23 ++++++++++++++++++++++- locales/uk/common.json | 23 ++++++++++++++++++++++- locales/zh-TW/common.json | 23 ++++++++++++++++++++++- locales/zh/common.json | 23 ++++++++++++++++++++++- 26 files changed, 572 insertions(+), 26 deletions(-) diff --git a/locales/ar/common.json b/locales/ar/common.json index 24f3f9268..9315a77ba 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "ثُبّتت هذه الإضافة قبل أن تصبح الموافقة مطلوبة. أزلها وثبّتها مرة أخرى لإرسالها للموافقة.", "error_approval_denied": "لم يوافق المسؤول على هذه الإضافة.", "error_approval_pending": "بانتظار موافقة المسؤول على هذه الإضافة.", - "error_auto_disabled": "تم إيقافها لأنها فشلت مرارًا. شغّلها مرة أخرى للمحاولة من جديد." + "error_auto_disabled": "تم إيقافها لأنها فشلت مرارًا. شغّلها مرة أخرى للمحاولة من جديد.", + "accounts_heading": "الحسابات المرتبطة", + "account_on": "{label} على {host}", + "account_waiting": "{label} على {host} (لم يكتمل تسجيل الدخول)", + "disconnect": "فصل", + "disconnect_failed": "تعذّر فصل {label}" }, "consent": { "title": "هل تسمح لـ {name} بذلك؟", @@ -4255,6 +4260,22 @@ "turn_off": "إيقاف {name}", "password_note": "هذا النموذج من الإضافة {name} وليس من بريد الويب. لا تدخل كلمة مرور إلا إذا كنت تثق بهذه الإضافة.", "toast_auto_disabled": "تم إيقاف {name} لأنها فشلت مرارًا." + }, + "connect": { + "title": "ربط {label}", + "intro_password": "يريد {name} استخدام حسابك على {label}. يحتفظ Bulwark بكلمة المرور على خادمه ولا يرسلها إلا إلى {host}. لا ترى الإضافة كلمة المرور أبدًا.", + "intro_password_any": "يريد {name} استخدام حسابك على {label}. يحتفظ Bulwark بكلمة المرور على خادمه ولا يرسلها إلا إلى الخادم الذي تدخله هنا. لا ترى الإضافة كلمة المرور أبدًا.", + "intro_signin": "يريد {name} تسجيل الدخول إلى حسابك على {label}. أدخل الخادم هنا، ثم سجّل الدخول في صفحة ذلك الخادم. يحتفظ Bulwark بالوصول الذي يحصل عليه على خادمه، ولا تراه الإضافة أبدًا.", + "server": "عنوان الخادم", + "server_hint": "cloud.example.com", + "username": "اسم المستخدم", + "secret": "كلمة المرور أو المفتاح", + "connect": "ربط", + "continue": "متابعة", + "cancel": "إلغاء", + "saving": "جارٍ الحفظ…", + "error_server": "أدخل عنوان الخادم، مثل https://cloud.example.com", + "error_save": "تعذّر الحفظ: {error}" } }, "global_search": { diff --git a/locales/ca/common.json b/locales/ca/common.json index d9ea2a1b8..f35f185f9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Aquest connector es va instal·lar abans que calgués l'aprovació. Elimineu-lo i torneu-lo a instal·lar per enviar-lo a aprovar.", "error_approval_denied": "L'administrador no ha aprovat aquest connector.", "error_approval_pending": "S'espera que l'administrador aprovi aquest connector.", - "error_auto_disabled": "S'ha desactivat perquè fallava repetidament. Torneu-lo a activar per tornar-ho a provar." + "error_auto_disabled": "S'ha desactivat perquè fallava repetidament. Torneu-lo a activar per tornar-ho a provar.", + "accounts_heading": "Comptes connectats", + "account_on": "{label} a {host}", + "account_waiting": "{label} a {host} (inici de sessió sense acabar)", + "disconnect": "Desconnecta", + "disconnect_failed": "No s'ha pogut desconnectar {label}" }, "consent": { "title": "Voleu permetre que {name} faci això?", @@ -4255,6 +4260,22 @@ "turn_off": "Desactiva {name}", "password_note": "Aquest formulari ve del connector {name}, no del correu web. Introduïu una contrasenya només si confieu en aquest connector.", "toast_auto_disabled": "{name} s'ha desactivat perquè fallava repetidament." + }, + "connect": { + "title": "Connecta {label}", + "intro_password": "{name} vol utilitzar el vostre compte de {label}. Bulwark desa la contrasenya al seu servidor i només l'envia a {host}. El connector no la veu mai.", + "intro_password_any": "{name} vol utilitzar el vostre compte de {label}. Bulwark desa la contrasenya al seu servidor i només l'envia al servidor que introduïu aquí. El connector no la veu mai.", + "intro_signin": "{name} vol iniciar la sessió al vostre compte de {label}. Introduïu aquí el servidor i després inicieu la sessió a la seva pròpia pàgina. Bulwark desa al seu servidor l'accés que obté; el connector no el veu mai.", + "server": "Adreça del servidor", + "server_hint": "cloud.example.com", + "username": "Nom d'usuari", + "secret": "Contrasenya o clau", + "connect": "Connecta", + "continue": "Continua", + "cancel": "Cancel·la", + "saving": "S'està desant…", + "error_server": "Introduïu l'adreça del servidor, per exemple https://cloud.example.com", + "error_save": "No s'ha pogut desar: {error}" } }, "global_search": { diff --git a/locales/cs/common.json b/locales/cs/common.json index aec9ada4e..9e6997745 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Tento plugin byl nainstalován dříve, než bylo vyžadováno schválení. Odstraňte ho a nainstalujte znovu, aby se odeslal ke schválení.", "error_approval_denied": "Váš správce tento plugin neschválil.", "error_approval_pending": "Čeká se, až správce tento plugin schválí.", - "error_auto_disabled": "Vypnuto, protože opakovaně selhávalo. Zapněte ho znovu a zkuste to znovu." + "error_auto_disabled": "Vypnuto, protože opakovaně selhávalo. Zapněte ho znovu a zkuste to znovu.", + "accounts_heading": "Připojené účty", + "account_on": "{label} na {host}", + "account_waiting": "{label} na {host} (přihlášení nedokončeno)", + "disconnect": "Odpojit", + "disconnect_failed": "{label} se nepodařilo odpojit" }, "consent": { "title": "Povolit pluginu {name} tyto akce?", @@ -4255,6 +4260,22 @@ "turn_off": "Vypnout {name}", "password_note": "Tento formulář pochází z pluginu {name}, ne z webmailu. Heslo zadejte jen tehdy, když tomuto pluginu důvěřujete.", "toast_auto_disabled": "{name} byl vypnut, protože opakovaně selhával." + }, + "connect": { + "title": "Připojit {label}", + "intro_password": "{name} chce používat váš účet {label}. Bulwark uchovává heslo na svém serveru a posílá ho jen na {host}. Plugin ho nikdy nevidí.", + "intro_password_any": "{name} chce používat váš účet {label}. Bulwark uchovává heslo na svém serveru a posílá ho jen na server, který zde zadáte. Plugin ho nikdy nevidí.", + "intro_signin": "{name} se chce přihlásit k vašemu účtu {label}. Zadejte zde server a pak se přihlaste na jeho vlastní stránce. Bulwark uchovává získaný přístup na svém serveru; plugin ho nikdy nevidí.", + "server": "Adresa serveru", + "server_hint": "cloud.example.com", + "username": "Uživatelské jméno", + "secret": "Heslo nebo klíč", + "connect": "Připojit", + "continue": "Pokračovat", + "cancel": "Zrušit", + "saving": "Ukládání…", + "error_server": "Zadejte adresu serveru, například https://cloud.example.com", + "error_save": "Uložení se nezdařilo: {error}" } }, "global_search": { diff --git a/locales/da/common.json b/locales/da/common.json index e099ca36a..6b734575c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Dette plugin blev installeret, før godkendelse var påkrævet. Fjern det, og installer det igen for at sende det til godkendelse.", "error_approval_denied": "Din administrator har ikke godkendt dette plugin.", "error_approval_pending": "Venter på, at din administrator godkender dette plugin.", - "error_auto_disabled": "Slået fra, fordi det blev ved med at fejle. Slå det til igen for at prøve igen." + "error_auto_disabled": "Slået fra, fordi det blev ved med at fejle. Slå det til igen for at prøve igen.", + "accounts_heading": "Forbundne konti", + "account_on": "{label} på {host}", + "account_waiting": "{label} på {host} (login ikke afsluttet)", + "disconnect": "Afbryd forbindelse", + "disconnect_failed": "Kunne ikke afbryde forbindelsen til {label}" }, "consent": { "title": "Vil du tillade {name} at gøre dette?", @@ -4255,6 +4260,22 @@ "turn_off": "Slå {name} fra", "password_note": "Denne formular kommer fra pluginet {name}, ikke fra webmailen. Indtast kun en adgangskode, hvis du stoler på dette plugin.", "toast_auto_disabled": "{name} blev slået fra, fordi det blev ved med at fejle." + }, + "connect": { + "title": "Forbind {label}", + "intro_password": "{name} vil bruge din konto hos {label}. Bulwark gemmer adgangskoden på sin server og sender den kun til {host}. Pluginnet ser den aldrig.", + "intro_password_any": "{name} vil bruge din konto hos {label}. Bulwark gemmer adgangskoden på sin server og sender den kun til den server, du angiver her. Pluginnet ser den aldrig.", + "intro_signin": "{name} vil logge ind på din konto hos {label}. Angiv serveren her, og log derefter ind på serverens egen side. Bulwark gemmer den adgang, det får, på sin server; pluginnet ser den aldrig.", + "server": "Serveradresse", + "server_hint": "cloud.example.com", + "username": "Brugernavn", + "secret": "Adgangskode eller nøgle", + "connect": "Forbind", + "continue": "Fortsæt", + "cancel": "Annuller", + "saving": "Gemmer…", + "error_server": "Angiv serverens adresse, for eksempel https://cloud.example.com", + "error_save": "Kunne ikke gemme: {error}" } }, "global_search": { diff --git a/locales/de/common.json b/locales/de/common.json index 2ba2ed0de..ce3f4ba02 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Dieses Plugin wurde installiert, bevor eine Freigabe nötig war. Entfernen Sie es und installieren Sie es erneut, um es zur Freigabe vorzulegen.", "error_approval_denied": "Ihr Administrator hat dieses Plugin nicht freigegeben.", "error_approval_pending": "Wartet darauf, dass Ihr Administrator dieses Plugin freigibt.", - "error_auto_disabled": "Ausgeschaltet, weil es wiederholt fehlgeschlagen ist. Schalten Sie es wieder ein, um es erneut zu versuchen." + "error_auto_disabled": "Ausgeschaltet, weil es wiederholt fehlgeschlagen ist. Schalten Sie es wieder ein, um es erneut zu versuchen.", + "accounts_heading": "Verbundene Konten", + "account_on": "{label} auf {host}", + "account_waiting": "{label} auf {host} (Anmeldung nicht abgeschlossen)", + "disconnect": "Trennen", + "disconnect_failed": "{label} konnte nicht getrennt werden" }, "consent": { "title": "{name} Folgendes erlauben?", @@ -4255,6 +4260,22 @@ "turn_off": "{name} ausschalten", "password_note": "Dieses Formular stammt vom Plugin {name}, nicht vom Webmail. Geben Sie ein Passwort nur ein, wenn Sie diesem Plugin vertrauen.", "toast_auto_disabled": "{name} wurde ausgeschaltet, weil es wiederholt fehlgeschlagen ist." + }, + "connect": { + "title": "{label} verbinden", + "intro_password": "{name} möchte Ihr Konto bei {label} verwenden. Bulwark speichert das Passwort auf seinem Server und sendet es nur an {host}. Das Plugin sieht es nie.", + "intro_password_any": "{name} möchte Ihr Konto bei {label} verwenden. Bulwark speichert das Passwort auf seinem Server und sendet es nur an den Server, den Sie hier eingeben. Das Plugin sieht es nie.", + "intro_signin": "{name} möchte sich bei Ihrem Konto bei {label} anmelden. Geben Sie hier den Server ein und melden Sie sich dann auf dessen eigener Seite an. Bulwark speichert den erhaltenen Zugang auf seinem Server; das Plugin sieht ihn nie.", + "server": "Serveradresse", + "server_hint": "cloud.example.com", + "username": "Benutzername", + "secret": "Passwort oder Schlüssel", + "connect": "Verbinden", + "continue": "Weiter", + "cancel": "Abbrechen", + "saving": "Wird gespeichert…", + "error_server": "Geben Sie die Adresse des Servers ein, zum Beispiel https://cloud.example.com", + "error_save": "Speichern fehlgeschlagen: {error}" } }, "global_search": { diff --git a/locales/es/common.json b/locales/es/common.json index 4a7136e37..f2595032a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Este complemento se instaló antes de que se exigiera aprobación. Elimínalo e instálalo de nuevo para enviarlo a aprobación.", "error_approval_denied": "Tu administrador no aprobó este complemento.", "error_approval_pending": "Esperando a que tu administrador apruebe este complemento.", - "error_auto_disabled": "Desactivado porque fallaba una y otra vez. Actívalo de nuevo para volver a intentarlo." + "error_auto_disabled": "Desactivado porque fallaba una y otra vez. Actívalo de nuevo para volver a intentarlo.", + "accounts_heading": "Cuentas conectadas", + "account_on": "{label} en {host}", + "account_waiting": "{label} en {host} (inicio de sesión sin terminar)", + "disconnect": "Desconectar", + "disconnect_failed": "No se pudo desconectar {label}" }, "consent": { "title": "¿Permitir que {name} haga esto?", @@ -4255,6 +4260,22 @@ "turn_off": "Desactivar {name}", "password_note": "Este formulario viene del complemento {name}, no del correo web. Introduce una contraseña solo si confías en este complemento.", "toast_auto_disabled": "{name} se desactivó porque fallaba una y otra vez." + }, + "connect": { + "title": "Conectar {label}", + "intro_password": "{name} quiere usar tu cuenta de {label}. Bulwark guarda la contraseña en su servidor y solo la envía a {host}. El complemento nunca la ve.", + "intro_password_any": "{name} quiere usar tu cuenta de {label}. Bulwark guarda la contraseña en su servidor y solo la envía al servidor que indiques aquí. El complemento nunca la ve.", + "intro_signin": "{name} quiere iniciar sesión en tu cuenta de {label}. Indica aquí el servidor y luego inicia sesión en su propia página. Bulwark guarda en su servidor el acceso que obtiene; el complemento nunca lo ve.", + "server": "Dirección del servidor", + "server_hint": "cloud.example.com", + "username": "Nombre de usuario", + "secret": "Contraseña o clave", + "connect": "Conectar", + "continue": "Continuar", + "cancel": "Cancelar", + "saving": "Guardando…", + "error_server": "Indica la dirección del servidor, por ejemplo https://cloud.example.com", + "error_save": "No se pudo guardar: {error}" } }, "global_search": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 2343dac11..f6d1b0917 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "این افزونه پیش از الزامی شدن تأیید نصب شده است. آن را حذف و دوباره نصب کنید تا برای تأیید ارسال شود.", "error_approval_denied": "مدیر سیستم این افزونه را تأیید نکرد.", "error_approval_pending": "در انتظار تأیید این افزونه توسط مدیر سیستم.", - "error_auto_disabled": "به‌دلیل خطاهای پی‌درپی خاموش شد. برای تلاش دوباره آن را روشن کنید." + "error_auto_disabled": "به‌دلیل خطاهای پی‌درپی خاموش شد. برای تلاش دوباره آن را روشن کنید.", + "accounts_heading": "حساب‌های متصل", + "account_on": "{label} روی {host}", + "account_waiting": "{label} روی {host} (ورود کامل نشده)", + "disconnect": "قطع اتصال", + "disconnect_failed": "قطع اتصال {label} ممکن نشد" }, "consent": { "title": "به {name} اجازه این کار داده شود؟", @@ -4255,6 +4260,22 @@ "turn_off": "خاموش کردن {name}", "password_note": "این فرم از افزونه {name} است، نه از وب‌میل. فقط اگر به این افزونه اعتماد دارید گذرواژه وارد کنید.", "toast_auto_disabled": "{name} به‌دلیل خطاهای پی‌درپی خاموش شد." + }, + "connect": { + "title": "اتصال {label}", + "intro_password": "{name} می‌خواهد از حساب {label} شما استفاده کند. Bulwark گذرواژه را روی سرور خودش نگه می‌دارد و فقط آن را به {host} می‌فرستد. افزونه هرگز آن را نمی‌بیند.", + "intro_password_any": "{name} می‌خواهد از حساب {label} شما استفاده کند. Bulwark گذرواژه را روی سرور خودش نگه می‌دارد و فقط آن را به سروری که اینجا وارد می‌کنید می‌فرستد. افزونه هرگز آن را نمی‌بیند.", + "intro_signin": "{name} می‌خواهد به حساب {label} شما وارد شود. سرور را اینجا وارد کنید، سپس در صفحهٔ خود آن سرور وارد شوید. Bulwark دسترسی‌ای را که می‌گیرد روی سرور خودش نگه می‌دارد؛ افزونه هرگز آن را نمی‌بیند.", + "server": "آدرس سرور", + "server_hint": "cloud.example.com", + "username": "نام کاربری", + "secret": "گذرواژه یا کلید", + "connect": "اتصال", + "continue": "ادامه", + "cancel": "انصراف", + "saving": "در حال ذخیره…", + "error_server": "آدرس سرور را وارد کنید، برای نمونه https://cloud.example.com", + "error_save": "ذخیره نشد: {error}" } }, "global_search": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 3527665e8..ed4496061 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Ce plugin a été installé avant que l'approbation soit requise. Supprimez-le et réinstallez-le pour l'envoyer en approbation.", "error_approval_denied": "Votre administrateur n'a pas approuvé ce plugin.", "error_approval_pending": "En attente de l'approbation de ce plugin par votre administrateur.", - "error_auto_disabled": "Désactivé parce qu'il échouait sans cesse. Réactivez-le pour réessayer." + "error_auto_disabled": "Désactivé parce qu'il échouait sans cesse. Réactivez-le pour réessayer.", + "accounts_heading": "Comptes connectés", + "account_on": "{label} sur {host}", + "account_waiting": "{label} sur {host} (connexion non terminée)", + "disconnect": "Déconnecter", + "disconnect_failed": "Impossible de déconnecter {label}" }, "consent": { "title": "Autoriser {name} à faire ceci ?", @@ -4255,6 +4260,22 @@ "turn_off": "Désactiver {name}", "password_note": "Ce formulaire provient du plugin {name}, pas du webmail. Ne saisissez un mot de passe que si vous faites confiance à ce plugin.", "toast_auto_disabled": "{name} a été désactivé parce qu'il échouait sans cesse." + }, + "connect": { + "title": "Connecter {label}", + "intro_password": "{name} souhaite utiliser votre compte {label}. Bulwark garde le mot de passe sur son serveur et ne l'envoie qu'à {host}. Le plugin ne le voit jamais.", + "intro_password_any": "{name} souhaite utiliser votre compte {label}. Bulwark garde le mot de passe sur son serveur et ne l'envoie qu'au serveur que vous saisissez ici. Le plugin ne le voit jamais.", + "intro_signin": "{name} souhaite se connecter à votre compte {label}. Saisissez le serveur ici, puis connectez-vous sur sa propre page. Bulwark garde l'accès obtenu sur son serveur ; le plugin ne le voit jamais.", + "server": "Adresse du serveur", + "server_hint": "cloud.example.com", + "username": "Nom d'utilisateur", + "secret": "Mot de passe ou clé", + "connect": "Connecter", + "continue": "Continuer", + "cancel": "Annuler", + "saving": "Enregistrement…", + "error_server": "Saisissez l'adresse du serveur, par exemple https://cloud.example.com", + "error_save": "Impossible d'enregistrer : {error}" } }, "global_search": { diff --git a/locales/he/common.json b/locales/he/common.json index 10b638bb2..4cc758ee6 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4020,7 +4020,12 @@ "error_approval_unavailable": "התוסף הזה הותקן לפני שנדרש אישור. הסירו אותו והתקינו אותו מחדש כדי לשלוח אותו לאישור.", "error_approval_denied": "מנהל המערכת לא אישר את התוסף הזה.", "error_approval_pending": "ממתין לאישור התוסף הזה על ידי מנהל המערכת.", - "error_auto_disabled": "כובה כי נכשל שוב ושוב. הפעילו אותו מחדש כדי לנסות שוב." + "error_auto_disabled": "כובה כי נכשל שוב ושוב. הפעילו אותו מחדש כדי לנסות שוב.", + "accounts_heading": "חשבונות מחוברים", + "account_on": "{label} בשרת {host}", + "account_waiting": "{label} בשרת {host} (ההתחברות לא הושלמה)", + "disconnect": "ניתוק", + "disconnect_failed": "לא ניתן לנתק את {label}" }, "consent": { "title": "לאפשר ל-{name} לעשות זאת?", @@ -4256,6 +4261,22 @@ "turn_off": "כיבוי {name}", "password_note": "הטופס הזה מגיע מהתוסף {name}, לא מהדואר האינטרנטי. הזינו סיסמה רק אם אתם סומכים על התוסף הזה.", "toast_auto_disabled": "{name} כובה כי נכשל שוב ושוב." + }, + "connect": { + "title": "חיבור {label}", + "intro_password": "{name} רוצה להשתמש בחשבון {label} שלכם. Bulwark שומר את הסיסמה בשרת שלו ושולח אותה רק אל {host}. התוסף אף פעם לא רואה אותה.", + "intro_password_any": "{name} רוצה להשתמש בחשבון {label} שלכם. Bulwark שומר את הסיסמה בשרת שלו ושולח אותה רק לשרת שתזינו כאן. התוסף אף פעם לא רואה אותה.", + "intro_signin": "{name} רוצה להתחבר לחשבון {label} שלכם. הזינו כאן את השרת, ואז התחברו בדף של השרת עצמו. Bulwark שומר בשרת שלו את הגישה שהוא מקבל; התוסף אף פעם לא רואה אותה.", + "server": "כתובת השרת", + "server_hint": "cloud.example.com", + "username": "שם משתמש", + "secret": "סיסמה או מפתח", + "connect": "חיבור", + "continue": "המשך", + "cancel": "ביטול", + "saving": "שומר…", + "error_server": "הזינו את כתובת השרת, למשל https://cloud.example.com", + "error_save": "השמירה נכשלה: {error}" } }, "global_search": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 550c4292e..1f359411e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Ezt a bővítményt még azelőtt telepítetted, hogy jóváhagyás kellett volna. Távolítsd el, majd telepítsd újra, hogy jóváhagyásra kerüljön.", "error_approval_denied": "A rendszergazda nem hagyta jóvá ezt a bővítményt.", "error_approval_pending": "Várakozás arra, hogy a rendszergazda jóváhagyja ezt a bővítményt.", - "error_auto_disabled": "Kikapcsolva, mert többször is hibát okozott. Kapcsolja be újra, ha ismét meg szeretné próbálni." + "error_auto_disabled": "Kikapcsolva, mert többször is hibát okozott. Kapcsolja be újra, ha ismét meg szeretné próbálni.", + "accounts_heading": "Csatlakoztatott fiókok", + "account_on": "{label} – {host}", + "account_waiting": "{label} – {host} (a bejelentkezés nincs befejezve)", + "disconnect": "Leválasztás", + "disconnect_failed": "Nem sikerült leválasztani: {label}" }, "consent": { "title": "{name}: engedélyezed ezeket a műveleteket?", @@ -4255,6 +4260,22 @@ "turn_off": "{name} kikapcsolása", "password_note": "Ez az űrlap a(z) {name} bővítménytől származik, nem a webmailtől. Csak akkor adjon meg jelszót, ha megbízik ebben a bővítményben.", "toast_auto_disabled": "{name} ki lett kapcsolva, mert többször is hibát okozott." + }, + "connect": { + "title": "{label} csatlakoztatása", + "intro_password": "A(z) {name} bővítmény használni szeretné a(z) {label} fiókodat. A Bulwark a saját szerverén tárolja a jelszót, és csak ide küldi el: {host}. A bővítmény soha nem látja.", + "intro_password_any": "A(z) {name} bővítmény használni szeretné a(z) {label} fiókodat. A Bulwark a saját szerverén tárolja a jelszót, és csak az itt megadott szervernek küldi el. A bővítmény soha nem látja.", + "intro_signin": "A(z) {name} bővítmény be szeretne jelentkezni a(z) {label} fiókodba. Add meg itt a szervert, majd jelentkezz be a szerver saját oldalán. A Bulwark a saját szerverén tárolja a kapott hozzáférést; a bővítmény soha nem látja.", + "server": "Szervercím", + "server_hint": "cloud.example.com", + "username": "Felhasználónév", + "secret": "Jelszó vagy kulcs", + "connect": "Csatlakoztatás", + "continue": "Tovább", + "cancel": "Mégse", + "saving": "Mentés…", + "error_server": "Add meg a szerver címét, például https://cloud.example.com", + "error_save": "Nem sikerült menteni: {error}" } }, "global_search": { diff --git a/locales/it/common.json b/locales/it/common.json index a738930ef..6b008aca9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Questo plugin è stato installato prima che fosse richiesta l'approvazione. Rimuovilo e installalo di nuovo per inviarlo in approvazione.", "error_approval_denied": "Il tuo amministratore non ha approvato questo plugin.", "error_approval_pending": "In attesa che il tuo amministratore approvi questo plugin.", - "error_auto_disabled": "Disattivato perché continuava a non funzionare. Riattivalo per riprovare." + "error_auto_disabled": "Disattivato perché continuava a non funzionare. Riattivalo per riprovare.", + "accounts_heading": "Account collegati", + "account_on": "{label} su {host}", + "account_waiting": "{label} su {host} (accesso non completato)", + "disconnect": "Disconnetti", + "disconnect_failed": "Impossibile disconnettere {label}" }, "consent": { "title": "Consentire a {name} di fare questo?", @@ -4255,6 +4260,22 @@ "turn_off": "Disattiva {name}", "password_note": "Questo modulo proviene dal plugin {name}, non dalla webmail. Inserisci una password solo se ti fidi di questo plugin.", "toast_auto_disabled": "{name} è stato disattivato perché continuava a non funzionare." + }, + "connect": { + "title": "Connetti {label}", + "intro_password": "{name} vuole usare il tuo account {label}. Bulwark conserva la password sul proprio server e la invia solo a {host}. Il plugin non la vede mai.", + "intro_password_any": "{name} vuole usare il tuo account {label}. Bulwark conserva la password sul proprio server e la invia solo al server che inserisci qui. Il plugin non la vede mai.", + "intro_signin": "{name} vuole accedere al tuo account {label}. Inserisci qui il server, poi accedi sulla sua pagina. Bulwark conserva sul proprio server l'accesso ottenuto; il plugin non lo vede mai.", + "server": "Indirizzo del server", + "server_hint": "cloud.example.com", + "username": "Nome utente", + "secret": "Password o chiave", + "connect": "Connetti", + "continue": "Continua", + "cancel": "Annulla", + "saving": "Salvataggio…", + "error_server": "Inserisci l'indirizzo del server, ad esempio https://cloud.example.com", + "error_save": "Impossibile salvare: {error}" } }, "global_search": { diff --git a/locales/ja/common.json b/locales/ja/common.json index b97e9101e..8f77f6163 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "このプラグインは承認が必要になる前にインストールされました。承認を依頼するには、削除してもう一度インストールしてください。", "error_approval_denied": "管理者はこのプラグインを承認しませんでした。", "error_approval_pending": "管理者がこのプラグインを承認するのを待っています。", - "error_auto_disabled": "繰り返し失敗したためオフになりました。再試行するにはもう一度オンにしてください。" + "error_auto_disabled": "繰り返し失敗したためオフになりました。再試行するにはもう一度オンにしてください。", + "accounts_heading": "接続済みのアカウント", + "account_on": "{host} の {label}", + "account_waiting": "{host} の {label} (サインイン未完了)", + "disconnect": "接続を解除", + "disconnect_failed": "{label} の接続を解除できませんでした" }, "consent": { "title": "{name} にこの操作を許可しますか?", @@ -4255,6 +4260,22 @@ "turn_off": "{name} をオフにする", "password_note": "このフォームはウェブメールではなく、プラグイン {name} が表示しています。このプラグインを信頼できる場合にのみパスワードを入力してください。", "toast_auto_disabled": "{name} は繰り返し失敗したためオフになりました。" + }, + "connect": { + "title": "{label} に接続", + "intro_password": "{name} が {label} アカウントを使おうとしています。Bulwark はパスワードを自身のサーバーに保存し、{host} にだけ送信します。プラグインがパスワードを見ることはありません。", + "intro_password_any": "{name} が {label} アカウントを使おうとしています。Bulwark はパスワードを自身のサーバーに保存し、ここで入力したサーバーにだけ送信します。プラグインがパスワードを見ることはありません。", + "intro_signin": "{name} が {label} アカウントにサインインしようとしています。ここでサーバーを入力し、そのサーバーのページでサインインしてください。Bulwark は取得したアクセス権を自身のサーバーに保存します。プラグインがそれを見ることはありません。", + "server": "サーバーのアドレス", + "server_hint": "cloud.example.com", + "username": "ユーザー名", + "secret": "パスワードまたはキー", + "connect": "接続", + "continue": "続行", + "cancel": "キャンセル", + "saving": "保存しています…", + "error_server": "サーバーのアドレスを入力してください (例: https://cloud.example.com)", + "error_save": "保存できませんでした: {error}" } }, "global_search": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 195cf5797..e2fe4e666 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "이 플러그인은 승인이 필요해지기 전에 설치되었어요. 승인을 요청하려면 삭제한 뒤 다시 설치하세요.", "error_approval_denied": "관리자가 이 플러그인을 승인하지 않았어요.", "error_approval_pending": "관리자가 이 플러그인을 승인하기를 기다리고 있어요.", - "error_auto_disabled": "계속 실패해서 꺼졌습니다. 다시 시도하려면 다시 켜세요." + "error_auto_disabled": "계속 실패해서 꺼졌습니다. 다시 시도하려면 다시 켜세요.", + "accounts_heading": "연결된 계정", + "account_on": "{host}의 {label}", + "account_waiting": "{host}의 {label} (로그인 미완료)", + "disconnect": "연결 해제", + "disconnect_failed": "{label} 연결을 해제하지 못했어요" }, "consent": { "title": "{name}에 이 권한을 허용할까요?", @@ -4255,6 +4260,22 @@ "turn_off": "{name} 끄기", "password_note": "이 양식은 웹메일이 아니라 플러그인 {name}에서 표시한 것입니다. 이 플러그인을 신뢰할 때만 비밀번호를 입력하세요.", "toast_auto_disabled": "{name}이(가) 계속 실패해서 꺼졌습니다." + }, + "connect": { + "title": "{label} 연결", + "intro_password": "{name}이(가) {label} 계정을 사용하려고 해요. Bulwark는 비밀번호를 자체 서버에 보관하고 {host}에만 보내요. 플러그인은 비밀번호를 볼 수 없어요.", + "intro_password_any": "{name}이(가) {label} 계정을 사용하려고 해요. Bulwark는 비밀번호를 자체 서버에 보관하고 여기에 입력한 서버에만 보내요. 플러그인은 비밀번호를 볼 수 없어요.", + "intro_signin": "{name}이(가) {label} 계정에 로그인하려고 해요. 여기에 서버를 입력한 다음 해당 서버의 페이지에서 로그인하세요. Bulwark는 받은 접근 권한을 자체 서버에 보관하며, 플러그인은 이를 볼 수 없어요.", + "server": "서버 주소", + "server_hint": "cloud.example.com", + "username": "사용자 이름", + "secret": "비밀번호 또는 키", + "connect": "연결", + "continue": "계속", + "cancel": "취소", + "saving": "저장하는 중…", + "error_server": "서버 주소를 입력하세요. 예: https://cloud.example.com", + "error_save": "저장하지 못했어요: {error}" } }, "global_search": { diff --git a/locales/lv/common.json b/locales/lv/common.json index cf298dfeb..53cc1ba63 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Šis spraudnis tika instalēts, pirms bija vajadzīgs apstiprinājums. Noņemiet to un instalējiet vēlreiz, lai nosūtītu apstiprināšanai.", "error_approval_denied": "Jūsu administrators neapstiprināja šo spraudni.", "error_approval_pending": "Gaida, kad jūsu administrators apstiprinās šo spraudni.", - "error_auto_disabled": "Izslēgts, jo atkārtoti neizdevās. Ieslēdziet to vēlreiz, lai mēģinātu vēlreiz." + "error_auto_disabled": "Izslēgts, jo atkārtoti neizdevās. Ieslēdziet to vēlreiz, lai mēģinātu vēlreiz.", + "accounts_heading": "Savienotie konti", + "account_on": "{label} serverī {host}", + "account_waiting": "{label} serverī {host} (pierakstīšanās nav pabeigta)", + "disconnect": "Atvienot", + "disconnect_failed": "Neizdevās atvienot {label}" }, "consent": { "title": "Atļaut spraudnim {name} to darīt?", @@ -4255,6 +4260,22 @@ "turn_off": "Izslēgt {name}", "password_note": "Šī veidlapa nāk no spraudņa {name}, nevis no tīmekļa pasta. Ievadiet paroli tikai tad, ja uzticaties šim spraudnim.", "toast_auto_disabled": "{name} tika izslēgts, jo atkārtoti neizdevās." + }, + "connect": { + "title": "Savienot {label}", + "intro_password": "{name} vēlas izmantot jūsu {label} kontu. Bulwark glabā paroli savā serverī un sūta to tikai uz {host}. Spraudnis to nekad neredz.", + "intro_password_any": "{name} vēlas izmantot jūsu {label} kontu. Bulwark glabā paroli savā serverī un sūta to tikai uz serveri, ko šeit ievadāt. Spraudnis to nekad neredz.", + "intro_signin": "{name} vēlas pierakstīties jūsu {label} kontā. Ievadiet šeit serveri un pēc tam pierakstieties šī servera lapā. Bulwark glabā iegūto piekļuvi savā serverī; spraudnis to nekad neredz.", + "server": "Servera adrese", + "server_hint": "cloud.example.com", + "username": "Lietotājvārds", + "secret": "Parole vai atslēga", + "connect": "Savienot", + "continue": "Turpināt", + "cancel": "Atcelt", + "saving": "Saglabā…", + "error_server": "Ievadiet servera adresi, piemēram, https://cloud.example.com", + "error_save": "Neizdevās saglabāt: {error}" } }, "global_search": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 21ba2eed0..29ccbf326 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Энэ залгаасыг зөвшөөрөл шаарддаг болохоос өмнө суулгасан. Зөвшөөрөлд илгээхийн тулд устгаад дахин суулгана уу.", "error_approval_denied": "Таны администратор энэ залгаасыг зөвшөөрөөгүй.", "error_approval_pending": "Таны администратор энэ залгаасыг зөвшөөрөхийг хүлээж байна.", - "error_auto_disabled": "Дахин дахин алдаа гаргасан тул унтраасан. Дахин оролдохын тулд асаана уу." + "error_auto_disabled": "Дахин дахин алдаа гаргасан тул унтраасан. Дахин оролдохын тулд асаана уу.", + "accounts_heading": "Холбогдсон бүртгэлүүд", + "account_on": "{host} дээрх {label}", + "account_waiting": "{host} дээрх {label} (нэвтрэлт дуусаагүй)", + "disconnect": "Салгах", + "disconnect_failed": "{label} салгаж чадсангүй" }, "consent": { "title": "{name} залгаасад үүнийг зөвшөөрөх үү?", @@ -4255,6 +4260,22 @@ "turn_off": "{name}-г унтраах", "password_note": "Энэ маягт вэб шуудангаас биш, {name} залгаасаас ирсэн. Энэ залгаасанд итгэдэг бол л нууц үгээ оруулна уу.", "toast_auto_disabled": "{name} дахин дахин алдаа гаргасан тул унтраагдлаа." + }, + "connect": { + "title": "{label} холбох", + "intro_password": "{name} таны {label} бүртгэлийг ашиглахыг хүсэж байна. Bulwark нууц үгийг өөрийн сервер дээр хадгалж, зөвхөн {host} руу илгээнэ. Залгаас үүнийг хэзээ ч харахгүй.", + "intro_password_any": "{name} таны {label} бүртгэлийг ашиглахыг хүсэж байна. Bulwark нууц үгийг өөрийн сервер дээр хадгалж, зөвхөн таны энд оруулсан сервер рүү илгээнэ. Залгаас үүнийг хэзээ ч харахгүй.", + "intro_signin": "{name} таны {label} бүртгэлд нэвтрэхийг хүсэж байна. Серверийг энд оруулаад, дараа нь тухайн серверийн өөрийн хуудсанд нэвтэрнэ үү. Bulwark олж авсан хандалтаа өөрийн сервер дээр хадгална; залгаас үүнийг хэзээ ч харахгүй.", + "server": "Серверийн хаяг", + "server_hint": "cloud.example.com", + "username": "Хэрэглэгчийн нэр", + "secret": "Нууц үг эсвэл түлхүүр", + "connect": "Холбох", + "continue": "Үргэлжлүүлэх", + "cancel": "Цуцлах", + "saving": "Хадгалж байна…", + "error_server": "Серверийн хаягийг оруулна уу, жишээ нь https://cloud.example.com", + "error_save": "Хадгалж чадсангүй: {error}" } }, "global_search": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 0ea3afcd0..9bb124ae7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Dette tillegget ble installert før godkjenning ble påkrevd. Fjern det og installer det på nytt for å sende det til godkjenning.", "error_approval_denied": "Administratoren din godkjente ikke dette tillegget.", "error_approval_pending": "Venter på at administratoren din skal godkjenne dette tillegget.", - "error_auto_disabled": "Slått av fordi det feilet gjentatte ganger. Slå det på igjen for å prøve på nytt." + "error_auto_disabled": "Slått av fordi det feilet gjentatte ganger. Slå det på igjen for å prøve på nytt.", + "accounts_heading": "Tilkoblede kontoer", + "account_on": "{label} på {host}", + "account_waiting": "{label} på {host} (pålogging ikke fullført)", + "disconnect": "Koble fra", + "disconnect_failed": "Kunne ikke koble fra {label}" }, "consent": { "title": "Vil du la {name} gjøre dette?", @@ -4255,6 +4260,22 @@ "turn_off": "Slå av {name}", "password_note": "Dette skjemaet kommer fra tillegget {name}, ikke fra webmailen. Skriv bare inn et passord hvis du stoler på dette tillegget.", "toast_auto_disabled": "{name} ble slått av fordi det feilet gjentatte ganger." + }, + "connect": { + "title": "Koble til {label}", + "intro_password": "{name} vil bruke kontoen din hos {label}. Bulwark lagrer passordet på sin server og sender det bare til {host}. Tillegget ser det aldri.", + "intro_password_any": "{name} vil bruke kontoen din hos {label}. Bulwark lagrer passordet på sin server og sender det bare til serveren du skriver inn her. Tillegget ser det aldri.", + "intro_signin": "{name} vil logge på kontoen din hos {label}. Skriv inn serveren her, og logg deretter på via serverens egen side. Bulwark lagrer tilgangen det får, på sin server; tillegget ser den aldri.", + "server": "Serveradresse", + "server_hint": "cloud.example.com", + "username": "Brukernavn", + "secret": "Passord eller nøkkel", + "connect": "Koble til", + "continue": "Fortsett", + "cancel": "Avbryt", + "saving": "Lagrer…", + "error_server": "Skriv inn adressen til serveren, for eksempel https://cloud.example.com", + "error_save": "Kunne ikke lagre: {error}" } }, "global_search": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 12acefe93..ced536260 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Deze plug-in is geïnstalleerd voordat goedkeuring verplicht was. Verwijder hem en installeer hem opnieuw om hem ter goedkeuring te sturen.", "error_approval_denied": "Je beheerder heeft deze plug-in niet goedgekeurd.", "error_approval_pending": "Wacht tot je beheerder deze plug-in goedkeurt.", - "error_auto_disabled": "Uitgezet omdat het steeds misging. Zet het weer aan om het opnieuw te proberen." + "error_auto_disabled": "Uitgezet omdat het steeds misging. Zet het weer aan om het opnieuw te proberen.", + "accounts_heading": "Gekoppelde accounts", + "account_on": "{label} op {host}", + "account_waiting": "{label} op {host} (inloggen niet afgerond)", + "disconnect": "Ontkoppelen", + "disconnect_failed": "Kan {label} niet ontkoppelen" }, "consent": { "title": "{name} dit toestaan?", @@ -4255,6 +4260,22 @@ "turn_off": "{name} uitzetten", "password_note": "Dit formulier komt van de plug-in {name}, niet van de webmail. Vul alleen een wachtwoord in als je deze plug-in vertrouwt.", "toast_auto_disabled": "{name} is uitgezet omdat het steeds misging." + }, + "connect": { + "title": "{label} koppelen", + "intro_password": "{name} wil je account bij {label} gebruiken. Bulwark bewaart het wachtwoord op zijn eigen server en stuurt het alleen naar {host}. De plug-in ziet het nooit.", + "intro_password_any": "{name} wil je account bij {label} gebruiken. Bulwark bewaart het wachtwoord op zijn eigen server en stuurt het alleen naar de server die je hier invult. De plug-in ziet het nooit.", + "intro_signin": "{name} wil inloggen op je account bij {label}. Vul hier de server in en log daarna in op de eigen pagina van die server. Bulwark bewaart de toegang die het krijgt op zijn eigen server; de plug-in ziet die nooit.", + "server": "Serveradres", + "server_hint": "cloud.example.com", + "username": "Gebruikersnaam", + "secret": "Wachtwoord of sleutel", + "connect": "Koppelen", + "continue": "Doorgaan", + "cancel": "Annuleren", + "saving": "Opslaan…", + "error_server": "Vul het adres van de server in, bijvoorbeeld https://cloud.example.com", + "error_save": "Opslaan mislukt: {error}" } }, "global_search": { diff --git a/locales/pl/common.json b/locales/pl/common.json index c0d941186..a5b5fc673 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Ta wtyczka została zainstalowana, zanim wprowadzono zatwierdzanie. Usuń ją i zainstaluj ponownie, aby wysłać ją do zatwierdzenia.", "error_approval_denied": "Administrator nie zatwierdził tej wtyczki.", "error_approval_pending": "Czeka na zatwierdzenie przez administratora.", - "error_auto_disabled": "Wyłączono, bo wielokrotnie zawodziło. Włącz ponownie, aby spróbować jeszcze raz." + "error_auto_disabled": "Wyłączono, bo wielokrotnie zawodziło. Włącz ponownie, aby spróbować jeszcze raz.", + "accounts_heading": "Połączone konta", + "account_on": "{label} na {host}", + "account_waiting": "{label} na {host} (logowanie niedokończone)", + "disconnect": "Rozłącz", + "disconnect_failed": "Nie udało się rozłączyć {label}" }, "consent": { "title": "Zezwolić wtyczce {name} na te działania?", @@ -4255,6 +4260,22 @@ "turn_off": "Wyłącz {name}", "password_note": "Ten formularz pochodzi z wtyczki {name}, a nie z poczty. Wpisz hasło tylko wtedy, gdy ufasz tej wtyczce.", "toast_auto_disabled": "{name} wyłączono, bo wielokrotnie zawodziło." + }, + "connect": { + "title": "Połącz z {label}", + "intro_password": "{name} chce używać Twojego konta {label}. Bulwark przechowuje hasło na swoim serwerze i wysyła je tylko do {host}. Wtyczka nigdy go nie widzi.", + "intro_password_any": "{name} chce używać Twojego konta {label}. Bulwark przechowuje hasło na swoim serwerze i wysyła je tylko do serwera, który tu wpiszesz. Wtyczka nigdy go nie widzi.", + "intro_signin": "{name} chce zalogować się na Twoje konto {label}. Wpisz tu serwer, a potem zaloguj się na jego stronie. Bulwark przechowuje uzyskany dostęp na swoim serwerze; wtyczka nigdy go nie widzi.", + "server": "Adres serwera", + "server_hint": "cloud.example.com", + "username": "Nazwa użytkownika", + "secret": "Hasło lub klucz", + "connect": "Połącz", + "continue": "Dalej", + "cancel": "Anuluj", + "saving": "Zapisywanie…", + "error_server": "Wpisz adres serwera, na przykład https://cloud.example.com", + "error_save": "Nie udało się zapisać: {error}" } }, "global_search": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 4b0c0702b..e749a7359 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Este plugin foi instalado antes de as aprovações serem exigidas. Remova-o e instale-o novamente para enviá-lo para aprovação.", "error_approval_denied": "Seu administrador não aprovou este plugin.", "error_approval_pending": "Aguardando o seu administrador aprovar este plugin.", - "error_auto_disabled": "Desativado porque falhava repetidamente. Ative-o de novo para tentar outra vez." + "error_auto_disabled": "Desativado porque falhava repetidamente. Ative-o de novo para tentar outra vez.", + "accounts_heading": "Contas conectadas", + "account_on": "{label} em {host}", + "account_waiting": "{label} em {host} (login não concluído)", + "disconnect": "Desconectar", + "disconnect_failed": "Não foi possível desconectar {label}" }, "consent": { "title": "Permitir que {name} faça isto?", @@ -4255,6 +4260,22 @@ "turn_off": "Desativar {name}", "password_note": "Este formulário vem do plugin {name}, não do webmail. Só introduza uma palavra-passe se confiar neste plugin.", "toast_auto_disabled": "{name} foi desativado porque falhava repetidamente." + }, + "connect": { + "title": "Conectar {label}", + "intro_password": "{name} quer usar sua conta {label}. O Bulwark guarda a senha no servidor dele e só a envia para {host}. O plugin nunca a vê.", + "intro_password_any": "{name} quer usar sua conta {label}. O Bulwark guarda a senha no servidor dele e só a envia para o servidor que você informar aqui. O plugin nunca a vê.", + "intro_signin": "{name} quer entrar na sua conta {label}. Informe o servidor aqui e depois entre na página dele. O Bulwark guarda no servidor dele o acesso que recebe; o plugin nunca o vê.", + "server": "Endereço do servidor", + "server_hint": "cloud.example.com", + "username": "Nome de usuário", + "secret": "Senha ou chave", + "connect": "Conectar", + "continue": "Continuar", + "cancel": "Cancelar", + "saving": "Salvando…", + "error_server": "Informe o endereço do servidor, por exemplo https://cloud.example.com", + "error_save": "Não foi possível salvar: {error}" } }, "global_search": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 9252a4761..6c41de36b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Acest plugin a fost instalat înainte ca aprobările să fie obligatorii. Eliminați-l și instalați-l din nou pentru a-l trimite spre aprobare.", "error_approval_denied": "Administratorul dvs. nu a aprobat acest plugin.", "error_approval_pending": "Se așteaptă ca administratorul să aprobe acest plugin.", - "error_auto_disabled": "Dezactivat pentru că a eșuat în mod repetat. Activați-l din nou pentru a reîncerca." + "error_auto_disabled": "Dezactivat pentru că a eșuat în mod repetat. Activați-l din nou pentru a reîncerca.", + "accounts_heading": "Conturi conectate", + "account_on": "{label} pe {host}", + "account_waiting": "{label} pe {host} (conectare neterminată)", + "disconnect": "Deconectați", + "disconnect_failed": "{label} nu a putut fi deconectat" }, "consent": { "title": "Permiteți ca {name} să facă acest lucru?", @@ -4255,6 +4260,22 @@ "turn_off": "Dezactivează {name}", "password_note": "Acest formular vine de la pluginul {name}, nu de la webmail. Introduceți o parolă doar dacă aveți încredere în acest plugin.", "toast_auto_disabled": "{name} a fost dezactivat pentru că a eșuat în mod repetat." + }, + "connect": { + "title": "Conectați {label}", + "intro_password": "{name} vrea să folosească contul dvs. {label}. Bulwark păstrează parola pe serverul său și o trimite doar către {host}. Pluginul nu o vede niciodată.", + "intro_password_any": "{name} vrea să folosească contul dvs. {label}. Bulwark păstrează parola pe serverul său și o trimite doar către serverul pe care îl introduceți aici. Pluginul nu o vede niciodată.", + "intro_signin": "{name} vrea să se conecteze la contul dvs. {label}. Introduceți aici serverul, apoi conectați-vă pe pagina acestuia. Bulwark păstrează pe serverul său accesul obținut; pluginul nu îl vede niciodată.", + "server": "Adresa serverului", + "server_hint": "cloud.example.com", + "username": "Nume de utilizator", + "secret": "Parolă sau cheie", + "connect": "Conectați", + "continue": "Continuați", + "cancel": "Anulați", + "saving": "Se salvează…", + "error_server": "Introduceți adresa serverului, de exemplu https://cloud.example.com", + "error_save": "Nu s-a putut salva: {error}" } }, "global_search": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 359f85ecb..52a518bed 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Этот плагин был установлен до того, как появилось одобрение. Удалите и установите его снова, чтобы отправить на одобрение.", "error_approval_denied": "Администратор не одобрил этот плагин.", "error_approval_pending": "Ожидается одобрение этого плагина администратором.", - "error_auto_disabled": "Отключён, потому что постоянно выдавал ошибки. Включите его снова, чтобы попробовать ещё раз." + "error_auto_disabled": "Отключён, потому что постоянно выдавал ошибки. Включите его снова, чтобы попробовать ещё раз.", + "accounts_heading": "Подключённые аккаунты", + "account_on": "{label} на {host}", + "account_waiting": "{label} на {host} (вход не завершён)", + "disconnect": "Отключить", + "disconnect_failed": "Не удалось отключить {label}" }, "consent": { "title": "Разрешить плагину {name} это?", @@ -4255,6 +4260,22 @@ "turn_off": "Отключить {name}", "password_note": "Эта форма от плагина {name}, а не от веб-почты. Вводите пароль, только если доверяете этому плагину.", "toast_auto_disabled": "{name} отключён, потому что постоянно выдавал ошибки." + }, + "connect": { + "title": "Подключить {label}", + "intro_password": "{name} хочет использовать ваш аккаунт {label}. Bulwark хранит пароль на своём сервере и отправляет его только на {host}. Плагин его никогда не видит.", + "intro_password_any": "{name} хочет использовать ваш аккаунт {label}. Bulwark хранит пароль на своём сервере и отправляет его только на сервер, который вы укажете здесь. Плагин его никогда не видит.", + "intro_signin": "{name} хочет войти в ваш аккаунт {label}. Укажите здесь сервер, затем войдите на его собственной странице. Bulwark хранит полученный доступ на своём сервере; плагин его никогда не видит.", + "server": "Адрес сервера", + "server_hint": "cloud.example.com", + "username": "Имя пользователя", + "secret": "Пароль или ключ", + "connect": "Подключить", + "continue": "Продолжить", + "cancel": "Отмена", + "saving": "Сохранение…", + "error_server": "Укажите адрес сервера, например https://cloud.example.com", + "error_save": "Не удалось сохранить: {error}" } }, "global_search": { diff --git a/locales/sk/common.json b/locales/sk/common.json index e63d2a6e9..8feaf38ec 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Tento doplnok bol nainštalovaný skôr, než sa vyžadovalo schválenie. Odstráňte ho a nainštalujte znova, aby sa odoslal na schválenie.", "error_approval_denied": "Váš správca tento doplnok neschválil.", "error_approval_pending": "Čaká sa, kým správca tento doplnok schváli.", - "error_auto_disabled": "Vypnuté, pretože opakovane zlyhávalo. Zapnite ho znova a skúste to znova." + "error_auto_disabled": "Vypnuté, pretože opakovane zlyhávalo. Zapnite ho znova a skúste to znova.", + "accounts_heading": "Pripojené účty", + "account_on": "{label} na {host}", + "account_waiting": "{label} na {host} (prihlásenie nedokončené)", + "disconnect": "Odpojiť", + "disconnect_failed": "{label} sa nepodarilo odpojiť" }, "consent": { "title": "Povoliť doplnku {name} tieto akcie?", @@ -4255,6 +4260,22 @@ "turn_off": "Vypnúť {name}", "password_note": "Tento formulár pochádza z doplnku {name}, nie z webmailu. Heslo zadajte, len ak tomuto doplnku dôverujete.", "toast_auto_disabled": "{name} bol vypnutý, pretože opakovane zlyhával." + }, + "connect": { + "title": "Pripojiť {label}", + "intro_password": "{name} chce používať váš účet {label}. Bulwark uchováva heslo na svojom serveri a posiela ho len na {host}. Doplnok ho nikdy nevidí.", + "intro_password_any": "{name} chce používať váš účet {label}. Bulwark uchováva heslo na svojom serveri a posiela ho len na server, ktorý tu zadáte. Doplnok ho nikdy nevidí.", + "intro_signin": "{name} sa chce prihlásiť do vášho účtu {label}. Zadajte tu server a potom sa prihláste na jeho vlastnej stránke. Bulwark uchováva získaný prístup na svojom serveri; doplnok ho nikdy nevidí.", + "server": "Adresa servera", + "server_hint": "cloud.example.com", + "username": "Používateľské meno", + "secret": "Heslo alebo kľúč", + "connect": "Pripojiť", + "continue": "Pokračovať", + "cancel": "Zrušiť", + "saving": "Ukladá sa…", + "error_server": "Zadajte adresu servera, napríklad https://cloud.example.com", + "error_save": "Uloženie zlyhalo: {error}" } }, "global_search": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 18127549b..b5f02a8b9 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Bu eklenti, onay zorunlu olmadan önce yüklendi. Onaya göndermek için kaldırıp yeniden yükleyin.", "error_approval_denied": "Yöneticiniz bu eklentiyi onaylamadı.", "error_approval_pending": "Bu eklentinin yöneticiniz tarafından onaylanması bekleniyor.", - "error_auto_disabled": "Sürekli hata verdiği için kapatıldı. Yeniden denemek için tekrar açın." + "error_auto_disabled": "Sürekli hata verdiği için kapatıldı. Yeniden denemek için tekrar açın.", + "accounts_heading": "Bağlı hesaplar", + "account_on": "{host} üzerinde {label}", + "account_waiting": "{host} üzerinde {label} (giriş tamamlanmadı)", + "disconnect": "Bağlantıyı kes", + "disconnect_failed": "{label} bağlantısı kesilemedi" }, "consent": { "title": "{name} eklentisine bu izin verilsin mi?", @@ -4255,6 +4260,22 @@ "turn_off": "{name} eklentisini kapat", "password_note": "Bu form web postasından değil, {name} eklentisinden geliyor. Yalnızca bu eklentiye güveniyorsanız parola girin.", "toast_auto_disabled": "{name} sürekli hata verdiği için kapatıldı." + }, + "connect": { + "title": "{label} hesabını bağla", + "intro_password": "{name}, {label} hesabınızı kullanmak istiyor. Bulwark parolayı kendi sunucusunda saklar ve yalnızca {host} adresine gönderir. Eklenti parolayı hiçbir zaman görmez.", + "intro_password_any": "{name}, {label} hesabınızı kullanmak istiyor. Bulwark parolayı kendi sunucusunda saklar ve yalnızca burada girdiğiniz sunucuya gönderir. Eklenti parolayı hiçbir zaman görmez.", + "intro_signin": "{name}, {label} hesabınıza giriş yapmak istiyor. Sunucuyu buraya girin, ardından sunucunun kendi sayfasında giriş yapın. Bulwark aldığı erişimi kendi sunucusunda saklar; eklenti bunu hiçbir zaman görmez.", + "server": "Sunucu adresi", + "server_hint": "cloud.example.com", + "username": "Kullanıcı adı", + "secret": "Parola veya anahtar", + "connect": "Bağla", + "continue": "Devam", + "cancel": "İptal", + "saving": "Kaydediliyor…", + "error_server": "Sunucunun adresini girin, örneğin https://cloud.example.com", + "error_save": "Kaydedilemedi: {error}" } }, "global_search": { diff --git a/locales/uk/common.json b/locales/uk/common.json index d28f9e356..a707d6252 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "Цей плагін встановлено ще до того, як з'явилося схвалення. Видаліть і встановіть його знову, щоб надіслати на схвалення.", "error_approval_denied": "Ваш адміністратор не схвалив цей плагін.", "error_approval_pending": "Очікується, що ваш адміністратор схвалить цей плагін.", - "error_auto_disabled": "Вимкнено, бо постійно виникали помилки. Увімкніть його знову, щоб спробувати ще раз." + "error_auto_disabled": "Вимкнено, бо постійно виникали помилки. Увімкніть його знову, щоб спробувати ще раз.", + "accounts_heading": "Підключені облікові записи", + "account_on": "{label} на {host}", + "account_waiting": "{label} на {host} (вхід не завершено)", + "disconnect": "Відключити", + "disconnect_failed": "Не вдалося відключити {label}" }, "consent": { "title": "Дозволити плагіну {name} це?", @@ -4255,6 +4260,22 @@ "turn_off": "Вимкнути {name}", "password_note": "Ця форма від плагіна {name}, а не від вебпошти. Вводьте пароль, лише якщо довіряєте цьому плагіну.", "toast_auto_disabled": "{name} вимкнено, бо постійно виникали помилки." + }, + "connect": { + "title": "Підключити {label}", + "intro_password": "{name} хоче використовувати ваш обліковий запис {label}. Bulwark зберігає пароль на своєму сервері й надсилає його лише на {host}. Плагін ніколи його не бачить.", + "intro_password_any": "{name} хоче використовувати ваш обліковий запис {label}. Bulwark зберігає пароль на своєму сервері й надсилає його лише на сервер, який ви вкажете тут. Плагін ніколи його не бачить.", + "intro_signin": "{name} хоче увійти до вашого облікового запису {label}. Вкажіть тут сервер, а потім увійдіть на його власній сторінці. Bulwark зберігає отриманий доступ на своєму сервері; плагін ніколи його не бачить.", + "server": "Адреса сервера", + "server_hint": "cloud.example.com", + "username": "Ім'я користувача", + "secret": "Пароль або ключ", + "connect": "Підключити", + "continue": "Продовжити", + "cancel": "Скасувати", + "saving": "Збереження…", + "error_server": "Вкажіть адресу сервера, наприклад https://cloud.example.com", + "error_save": "Не вдалося зберегти: {error}" } }, "global_search": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 787a71aba..5ad4a6232 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "此外掛程式是在需要核准之前安裝的。請移除後重新安裝,以送交核准。", "error_approval_denied": "管理員未核准此外掛程式。", "error_approval_pending": "正在等待管理員核准此外掛程式。", - "error_auto_disabled": "由於反覆出錯已關閉。重新開啟即可再試一次。" + "error_auto_disabled": "由於反覆出錯已關閉。重新開啟即可再試一次。", + "accounts_heading": "已連線的帳號", + "account_on": "{host} 上的 {label}", + "account_waiting": "{host} 上的 {label}(尚未完成登入)", + "disconnect": "中斷連線", + "disconnect_failed": "無法中斷 {label} 的連線" }, "consent": { "title": "要允許 {name} 執行這些動作嗎?", @@ -4255,6 +4260,22 @@ "turn_off": "關閉 {name}", "password_note": "此表單來自外掛程式 {name},而不是網頁郵件。只有在您信任此外掛程式時才輸入密碼。", "toast_auto_disabled": "{name} 因反覆出錯已被關閉。" + }, + "connect": { + "title": "連線至 {label}", + "intro_password": "{name} 想使用您的 {label} 帳號。Bulwark 會將密碼保存在自己的伺服器上,只會傳送給 {host}。外掛程式永遠看不到密碼。", + "intro_password_any": "{name} 想使用您的 {label} 帳號。Bulwark 會將密碼保存在自己的伺服器上,只會傳送給您在此輸入的伺服器。外掛程式永遠看不到密碼。", + "intro_signin": "{name} 想登入您的 {label} 帳號。請在此輸入伺服器,然後在該伺服器自己的頁面上登入。Bulwark 會將取得的存取權保存在自己的伺服器上;外掛程式永遠看不到它。", + "server": "伺服器位址", + "server_hint": "cloud.example.com", + "username": "使用者名稱", + "secret": "密碼或金鑰", + "connect": "連線", + "continue": "繼續", + "cancel": "取消", + "saving": "正在儲存…", + "error_server": "請輸入伺服器位址,例如 https://cloud.example.com", + "error_save": "無法儲存:{error}" } }, "global_search": { diff --git a/locales/zh/common.json b/locales/zh/common.json index ec47531a7..99ab8a27a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "此插件是在需要审批之前安装的。请移除后重新安装,以提交审批。", "error_approval_denied": "管理员未批准此插件。", "error_approval_pending": "正在等待管理员批准此插件。", - "error_auto_disabled": "由于反复出错已关闭。重新开启即可再试一次。" + "error_auto_disabled": "由于反复出错已关闭。重新开启即可再试一次。", + "accounts_heading": "已连接的账户", + "account_on": "{host} 上的 {label}", + "account_waiting": "{host} 上的 {label}(尚未完成登录)", + "disconnect": "断开连接", + "disconnect_failed": "无法断开 {label} 的连接" }, "consent": { "title": "允许 {name} 执行这些操作?", @@ -4255,6 +4260,22 @@ "turn_off": "关闭 {name}", "password_note": "此表单来自插件 {name},而不是网页邮箱。只有在您信任此插件时才输入密码。", "toast_auto_disabled": "{name} 因反复出错已被关闭。" + }, + "connect": { + "title": "连接 {label}", + "intro_password": "{name} 想使用您的 {label} 账户。Bulwark 将密码保存在自己的服务器上,只会发送给 {host}。插件永远看不到密码。", + "intro_password_any": "{name} 想使用您的 {label} 账户。Bulwark 将密码保存在自己的服务器上,只会发送给您在此处输入的服务器。插件永远看不到密码。", + "intro_signin": "{name} 想登录您的 {label} 账户。请在此处输入服务器,然后在该服务器自己的页面上登录。Bulwark 将获得的访问权限保存在自己的服务器上;插件永远看不到它。", + "server": "服务器地址", + "server_hint": "cloud.example.com", + "username": "用户名", + "secret": "密码或密钥", + "connect": "连接", + "continue": "继续", + "cancel": "取消", + "saving": "正在保存…", + "error_server": "请输入服务器地址,例如 https://cloud.example.com", + "error_save": "无法保存:{error}" } }, "global_search": { From ead925c1b8f01bee3122cc255e7ff0cbb91b7545 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:23:17 +0200 Subject: [PATCH 075/147] feat: let plugins open a filled-in composer and save drafts --- .../mail/__tests__/mailto-draft.test.ts | 27 +++ components/mail/mail-app.tsx | 21 +-- components/mail/mailto-draft.ts | 34 ++++ .../plugin-host-compose-drafts.test.ts | 144 ++++++++++++++++ lib/plugin-sandbox/host-api-compose.ts | 154 ++++++++++++++++++ lib/plugin-sandbox/host-api.ts | 8 + lib/plugin-sandbox/runtime.tsx | 23 ++- lib/plugin-types.ts | 37 +++++ lib/protocol-handlers/mailto.ts | 19 ++- locales/ar/common.json | 6 +- locales/ca/common.json | 6 +- locales/cs/common.json | 6 +- locales/da/common.json | 6 +- locales/de/common.json | 6 +- locales/en/common.json | 6 +- locales/es/common.json | 6 +- locales/fa/common.json | 6 +- locales/fr/common.json | 6 +- locales/he/common.json | 6 +- locales/hu/common.json | 6 +- locales/it/common.json | 6 +- locales/ja/common.json | 6 +- locales/ko/common.json | 6 +- locales/lv/common.json | 6 +- locales/mn/common.json | 6 +- locales/nb/common.json | 6 +- locales/nl/common.json | 6 +- locales/pl/common.json | 6 +- locales/pt/common.json | 6 +- locales/ro/common.json | 6 +- locales/ru/common.json | 6 +- locales/sk/common.json | 6 +- locales/tr/common.json | 6 +- locales/uk/common.json | 6 +- locales/zh-TW/common.json | 6 +- locales/zh/common.json | 6 +- 36 files changed, 582 insertions(+), 47 deletions(-) create mode 100644 components/mail/__tests__/mailto-draft.test.ts create mode 100644 components/mail/mailto-draft.ts create mode 100644 lib/__tests__/plugin-host-compose-drafts.test.ts create mode 100644 lib/plugin-sandbox/host-api-compose.ts diff --git a/components/mail/__tests__/mailto-draft.test.ts b/components/mail/__tests__/mailto-draft.test.ts new file mode 100644 index 000000000..33756ba47 --- /dev/null +++ b/components/mail/__tests__/mailto-draft.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { mailtoComposerDraft } from "../mailto-draft"; + +const base = { to: ["bob@example.com"], cc: [], bcc: ["x@example.com"], subject: "Hi", body: "Line 1\nLine 2" }; + +describe("mailtoComposerDraft", () => { + it("keeps a mailto: request as before: plain body, format from the setting", () => { + const plain = mailtoComposerDraft(base, true); + expect(plain).toEqual(expect.objectContaining({ + to: "bob@example.com", bcc: "x@example.com", showBcc: true, subject: "Hi", body: "Line 1\nLine 2", mode: "compose", + })); + expect(plain).not.toHaveProperty("plainTextMode"); + expect(plain).not.toHaveProperty("attachments"); + expect(mailtoComposerDraft(base, false).body).not.toBe(base.body); + }); + + it("opens a plugin's HTML body in HTML mode, even with plain text on", () => { + const draft = mailtoComposerDraft({ ...base, htmlBody: "

Hello you

" }, true); + expect(draft.body).toBe("

Hello you

"); + expect(draft.plainTextMode).toBe(false); + }); + + it("carries a plugin's uploaded attachments", () => { + const attachments = [{ blobId: "b1", name: "a.pdf", type: "application/pdf", size: 10 }]; + expect(mailtoComposerDraft({ ...base, attachments }, false).attachments).toEqual(attachments); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 4eca088ca..3000636f0 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -114,7 +114,8 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useThemeStore } from "@/stores/theme-store"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; -import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils"; +import { mailtoComposerDraft } from "./mailto-draft"; +import { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; @@ -1102,27 +1103,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [initialCheckDone, isAuthenticated, authLoading]); const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => { - const body = useSettingsStore.getState().plainTextMode - ? pending.body - : plainTextToComposerBody(pending.body); - if (suppressOutgoingStash) { suppressComposerStateSaveSessionRef.current = composerSessionId; } setComposerSessionId((id) => id + 1); - setPendingDraft({ - to: pending.to.join(", "), - cc: pending.cc.join(", "), - bcc: pending.bcc.join(", "), - subject: pending.subject, - body, - showCc: pending.cc.length > 0, - showBcc: pending.bcc.length > 0, - selectedIdentityId: null, - subAddressTag: "", - mode: "compose", - draftId: null, - }); + setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode)); setComposerMode("compose"); setShowComposer(true); if (isMobile) setActiveView("viewer"); diff --git a/components/mail/mailto-draft.ts b/components/mail/mailto-draft.ts new file mode 100644 index 000000000..2adeb5c52 --- /dev/null +++ b/components/mail/mailto-draft.ts @@ -0,0 +1,34 @@ +import type { ComposerDraftData } from "@/components/email/email-composer"; +import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; +import { plainTextToComposerBody } from "@/lib/email-composer-utils"; + +/** + * The composer draft for an in-app mailto: request or a plugin's + * api.compose.open. A plain body follows the "plain text only" setting; an + * HTML body (only a plugin sends one, sanitised by the host) opens in HTML + * mode whatever that setting says. Attachments a plugin uploaded come along + * as blob references, like those of a re-opened draft. + */ +export function mailtoComposerDraft(pending: ParsedMailto, plainTextMode: boolean): ComposerDraftData { + const isHtml = typeof pending.htmlBody === "string"; + const body = isHtml + ? pending.htmlBody! + : plainTextMode + ? pending.body + : plainTextToComposerBody(pending.body); + return { + to: pending.to.join(", "), + cc: pending.cc.join(", "), + bcc: pending.bcc.join(", "), + subject: pending.subject, + body, + showCc: pending.cc.length > 0, + showBcc: pending.bcc.length > 0, + selectedIdentityId: null, + subAddressTag: "", + mode: "compose", + draftId: null, + ...(isHtml ? { plainTextMode: false } : {}), + ...(pending.attachments?.length ? { attachments: pending.attachments } : {}), + }; +} diff --git a/lib/__tests__/plugin-host-compose-drafts.test.ts b/lib/__tests__/plugin-host-compose-drafts.test.ts new file mode 100644 index 000000000..64616ed15 --- /dev/null +++ b/lib/__tests__/plugin-host-compose-drafts.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from '../protocol-handlers/mailto'; +import { consumePendingMailto } from '../protocol-handlers/session'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-compose'; +import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; +import { ALL_PERMISSIONS } from '../plugin-types'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +function plugin(perms: string[], id = 'cloud'): InstalledPlugin { + return { + id, name: 'Cloud', version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const COMPOSER = plugin(['compose:open']); +const DRAFTER = plugin(['email:write']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); +const pdf = { blobId: 'b1', name: 'report.pdf', type: 'application/pdf', size: 1200 }; + +describe('api.compose.open', () => { + let taken: ParsedMailto[]; + const listener = (event: Event) => { + event.preventDefault(); + taken.push((event as CustomEvent).detail); + }; + + beforeEach(() => { + taken = []; + resetPluginRateLimits(); + window.addEventListener(INTERNAL_MAILTO_EVENT, listener); + }); + afterEach(() => { + window.removeEventListener(INTERNAL_MAILTO_EVENT, listener); + sessionStorage.clear(); + }); + + it('hands the mail page a filled-in draft, like an in-app mailto: link', async () => { + expect(await call(COMPOSER, 'compose.open', { + to: ['Bob '], cc: ['carol@example.com'], subject: 'Report', body: 'See attached', attachments: [pdf], + })).toBe('opened'); + expect(taken).toEqual([{ + to: ['Bob '], cc: ['carol@example.com'], bcc: [], subject: 'Report', body: 'See attached', attachments: [pdf], + }]); + }); + + it('sanitises an HTML body and sends it as htmlBody', async () => { + await call(COMPOSER, 'compose.open', { body: '

Hi there

', isHtml: true }); + expect(taken[0].htmlBody).toContain('there'); + expect(taken[0].htmlBody).not.toContain('onerror'); + expect(taken[0].body).toMatch(/Hi there/); + }); + + it('parks the draft for the mail page when none is on screen', async () => { + window.removeEventListener(INTERNAL_MAILTO_EVENT, listener); + // jsdom does not navigate; the call still reports where it is going. + expect(await call(COMPOSER, 'compose.open', { to: ['bob@example.com'], subject: 'Hi' })).toBe('navigating'); + expect(consumePendingMailto()).toEqual(expect.objectContaining({ to: ['bob@example.com'], subject: 'Hi' })); + }); + + it('refuses bad addresses, header injection, too many recipients and odd attachments', async () => { + await expect(call(COMPOSER, 'compose.open', { to: ['nobody'] })).rejects.toThrow(/to\[0\] is not a valid address/); + await expect(call(COMPOSER, 'compose.open', { subject: 'Hi\r\nBcc: x@example.com' })).rejects.toThrow(/subject must be one line/); + const many = Array.from({ length: 101 }, (_, i) => `u${i}@example.com`); + await expect(call(COMPOSER, 'compose.open', { to: many })).rejects.toThrow(/at most 100 addresses/); + await expect(call(COMPOSER, 'compose.open', { to: many.slice(0, 60), cc: many.slice(60) })).rejects.toThrow(/at most 100 recipients in all/); + await expect(call(COMPOSER, 'compose.open', { attachments: [{ ...pdf, type: 'not a type' }] })).rejects.toThrow(/not a media type/); + await expect(call(COMPOSER, 'compose.open', { from: 'boss@example.com' })).rejects.toThrow(/unknown property "from"/); + expect(taken).toEqual([]); + }); + + it('opens at most five times a minute per plugin', async () => { + for (let i = 0; i < 5; i++) await call(COMPOSER, 'compose.open', {}); + await expect(call(COMPOSER, 'compose.open', {})).rejects.toThrow(/at most 5 times a minute/); + expect(await call(plugin(['compose:open'], 'other'), 'compose.open', {})).toBe('opened'); + expect(taken).toHaveLength(6); + }); + + it('needs compose:open, a new permission that is not marked high risk', async () => { + await expect(call(plugin(['email:send']), 'compose.open', {})).rejects.toThrow(/lacks permission "compose:open"/); + expect(taken).toEqual([]); + expect(ALL_PERMISSIONS).toContain('compose:open'); + expect(HIGH_RISK_PERMISSIONS.has('compose:open')).toBe(false); + }); +}); + +describe('api.drafts.create', () => { + let createDraft: ReturnType; + + beforeEach(() => { + resetPluginRateLimits(); + createDraft = vi.fn(async () => 'draft-1'); + useAuthStore.setState({ client: { createDraft } as unknown as IJMAPClient }); + useIdentityStore.setState({ + identities: [ + { id: 'id-a', name: 'Ann', email: 'ann@example.com' }, + { id: 'id-b', name: 'Ann Work', email: 'ann@work.example' }, + ] as never, + preferredPrimaryId: 'id-b', + }); + }); + afterEach(() => { + useAuthStore.setState({ client: null }); + useIdentityStore.setState({ identities: [], preferredPrimaryId: null }); + }); + + it('saves a draft from the default identity and returns its id', async () => { + expect(await call(DRAFTER, 'drafts.create', { + to: ['bob@example.com'], subject: 'Notes', body: '

Hello

', isHtml: true, attachments: [pdf], + })).toBe('draft-1'); + const [to, subject, text, cc, bcc, identityId, fromEmail, draftId, attachments, fromName, html] = createDraft.mock.calls[0]; + expect({ to, subject, cc, bcc, identityId, fromEmail, draftId, fromName }).toEqual({ + to: ['bob@example.com'], subject: 'Notes', cc: [], bcc: [], identityId: 'id-b', + fromEmail: 'ann@work.example', draftId: undefined, fromName: 'Ann Work', + }); + expect(text).toMatch(/Hello/); + expect(html).toContain('

Hello

'); + expect(html).not.toContain('script'); + expect(attachments).toEqual([{ ...pdf, disposition: 'attachment' }]); + }); + + it('needs email:write and valid input', async () => { + await expect(call(plugin(['compose:open']), 'drafts.create', {})).rejects.toThrow(/lacks permission "email:write"/); + await expect(call(DRAFTER, 'drafts.create', { to: 'bob@example.com' })).rejects.toThrow(/to must be an array/); + await expect(call(DRAFTER, 'drafts.create', { attachments: [{ blobId: 'b1' }] })).rejects.toThrow(/attachments\[0\]\.type must be a string/); + expect(createDraft).not.toHaveBeenCalled(); + }); + + it('saves at most ten drafts a minute', async () => { + for (let i = 0; i < 10; i++) await call(DRAFTER, 'drafts.create', {}); + await expect(call(DRAFTER, 'drafts.create', {})).rejects.toThrow(/at most 10 drafts a minute/); + expect(createDraft).toHaveBeenCalledTimes(10); + }); +}); diff --git a/lib/plugin-sandbox/host-api-compose.ts b/lib/plugin-sandbox/host-api-compose.ts new file mode 100644 index 000000000..c3f77c664 --- /dev/null +++ b/lib/plugin-sandbox/host-api-compose.ts @@ -0,0 +1,154 @@ +// Host side of api.compose.open and api.drafts.create. compose.open fills +// the composer the way an in-app mailto: link does, and the user still has +// to press Send; drafts.create saves a draft to the Drafts folder without +// opening anything. Neither sends mail. + +import type { ComposeInput, DraftInput, InstalledPlugin } from '../plugin-types'; +import type { ParsedMailto } from '../protocol-handlers/mailto'; +import { requestInternalCompose } from '../protocol-handlers/mailto'; +import { notifyPendingMailto, savePendingMailto } from '../protocol-handlers/session'; +import { getPathPrefix } from '../browser-navigation'; +import { locales } from '@/i18n/routing'; +import { useIdentityStore } from '@/stores/identity-store'; +import { sanitizePluginBodyHtml } from '../email-sanitization'; +import { htmlToPlainText } from '../html-to-text'; +import { + readAddressList, readBlobRefs, readBoolean, readObject, readString, sessionClient, type PluginBlobRef, +} from './host-api-input'; + +/** Most recipients (to, cc and bcc together) one call may set. */ +export const MAX_PLUGIN_RECIPIENTS = 100; +/** Most attachments one call may add. */ +export const MAX_PLUGIN_ATTACHMENTS = 20; +const MAX_SUBJECT = 998; +const MAX_BODY = 512 * 1024; +// A blob a plugin uploaded can be as big as the server lets it be; this +// only keeps the number sane. +const MAX_ATTACHMENT_SIZE = 2 ** 31; + +interface MessageFields { + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + text: string; + html: string | undefined; + attachments: PluginBlobRef[]; +} + +function readMessage(method: string, data: Record): MessageFields { + const to = readAddressList(data.to, method, 'to', MAX_PLUGIN_RECIPIENTS); + const cc = readAddressList(data.cc, method, 'cc', MAX_PLUGIN_RECIPIENTS); + const bcc = readAddressList(data.bcc, method, 'bcc', MAX_PLUGIN_RECIPIENTS); + if (to.length + cc.length + bcc.length > MAX_PLUGIN_RECIPIENTS) { + throw new Error(`${method}: at most ${MAX_PLUGIN_RECIPIENTS} recipients in all`); + } + const subject = data.subject === undefined ? '' : readString(data.subject, method, 'subject', MAX_SUBJECT); + // eslint-disable-next-line no-control-regex -- a subject is one header line + if (/[\u0000-\u001f\u007f]/.test(subject)) throw new Error(`${method}: subject must be one line`); + const body = data.body === undefined ? '' : readString(data.body, method, 'body', MAX_BODY); + const isHtml = data.isHtml === undefined ? false : readBoolean(data.isHtml, method, 'isHtml'); + const html = isHtml ? sanitizePluginBodyHtml(body) : undefined; + return { + to, cc, bcc, subject, + text: html !== undefined ? htmlToPlainText(html) : body, + html, + attachments: readBlobRefs(data.attachments, method, MAX_PLUGIN_ATTACHMENTS, MAX_ATTACHMENT_SIZE), + }; +} + +// ─── Rate limits ────────────────────────────────────────────── + +const recentCalls = new Map(); + +/** True when `pluginId` made fewer than `max` `kind` calls in the last minute; records this one. */ +export function withinRate(kind: string, pluginId: string, max: number, windowMs = 60_000): boolean { + const key = `${kind}\u0000${pluginId}`; + const now = Date.now(); + const recent = (recentCalls.get(key) ?? []).filter((t) => now - t < windowMs); + if (recent.length >= max) { + recentCalls.set(key, recent); + return false; + } + recent.push(now); + recentCalls.set(key, recent); + return true; +} + +/** Forget the rate-limit history (tests). */ +export function resetPluginRateLimits(): void { + recentCalls.clear(); +} + +// ─── compose.open ───────────────────────────────────────────── + +const COMPOSE_PER_MINUTE = 5; + +/** The mail page in the current mount and locale. */ +function mailPageHref(): string { + const segments = window.location.pathname.split('/').filter(Boolean); + const localeIndex = segments.findIndex((s) => (locales as readonly string[]).includes(s)); + if (localeIndex >= 0) return '/' + segments.slice(0, localeIndex + 1).join('/') + '/'; + return getPathPrefix() + '/'; +} + +/** + * Open the composer filled in. On the mail page it opens in place (asking + * first when a draft is already open); elsewhere the app goes to the mail + * page, which opens it. Resolves to 'opened' or 'navigating'. + */ +export function doComposeOpen(plugin: InstalledPlugin, input: unknown): 'opened' | 'navigating' { + const method = 'compose.open'; + const data = readObject(input, method, 'message', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as ComposeInput & Record; + const message = readMessage(method, data); + if (!withinRate('compose', plugin.id, COMPOSE_PER_MINUTE)) { + throw new Error(`${method}: at most ${COMPOSE_PER_MINUTE} times a minute`); + } + const draft: ParsedMailto = { + to: message.to, + cc: message.cc, + bcc: message.bcc, + subject: message.subject, + body: message.text, + ...(message.html !== undefined ? { htmlBody: message.html } : {}), + ...(message.attachments.length ? { attachments: message.attachments } : {}), + }; + if (requestInternalCompose(draft)) return 'opened'; + // No mail page on screen: park the draft where the mail page looks for an + // incoming mailto: request when it mounts, and go there. + savePendingMailto(draft); + notifyPendingMailto(); + window.location.assign(mailPageHref()); + return 'navigating'; +} + +// ─── drafts.create ──────────────────────────────────────────── + +const DRAFTS_PER_MINUTE = 10; + +/** Save a draft in the Drafts folder, from the user's default identity. Resolves to its id. */ +export async function doDraftsCreate(plugin: InstalledPlugin, input: unknown): Promise { + const method = 'drafts.create'; + const data = readObject(input, method, 'draft', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as DraftInput & Record; + const message = readMessage(method, data); + const client = sessionClient(method); + const { identities, preferredPrimaryId } = useIdentityStore.getState(); + const identity = identities.find((i) => i.id === preferredPrimaryId) ?? identities[0]; + if (!identity) throw new Error(`${method}: the account has no sender address to save the draft from`); + if (!withinRate('drafts', plugin.id, DRAFTS_PER_MINUTE)) { + throw new Error(`${method}: at most ${DRAFTS_PER_MINUTE} drafts a minute`); + } + return client.createDraft( + message.to, + message.subject, + message.text, + message.cc, + message.bcc, + identity.id, + identity.email, + undefined, + message.attachments.map((a) => ({ ...a, disposition: 'attachment' as const })), + identity.name || undefined, + message.html, + ); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 07ace0745..9a0666b6e 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -45,6 +45,7 @@ import { doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doMailboxesList, } from './host-api-mail'; import { MAX_PLUGIN_FETCH_BYTES } from './host-api-input'; +import { doComposeOpen, doDraftsCreate } from './host-api-compose'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -222,6 +223,10 @@ export const PERM_PER_METHOD: Readonly> = { // plugin names, so the untrusted tier may call them. 'email.getAttachment': 'email:blob-read', 'email.getRaw': 'email:blob-read', + // Open the composer filled in; the user still decides whether to send. + 'compose.open': 'compose:open', + // Save a draft without opening the composer. Nothing is sent. + 'drafts.create': 'email:write', }; // ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ────── @@ -1912,6 +1917,9 @@ export async function dispatchApiCall( case 'email.getAttachment': return doEmailGetAttachment(args[0], args[1]); case 'email.getRaw': return doEmailGetRaw(args[0]); + case 'compose.open': return doComposeOpen(plugin, args[0]); + case 'drafts.create': return doDraftsCreate(plugin, args[0]); + default: throw new Error(`Unhandled method "${method}"`); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 24ad08211..f8dd275ff 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -31,7 +31,7 @@ import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - CalendarEventInput, CalendarEventView, CalendarView, EmailReadView, EmailSummary, IdentityPatch, + CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, EmailSummary, IdentityPatch, IdentityView, MailboxView, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; @@ -459,6 +459,27 @@ function buildPluginApi(manifest: PluginManifest) { removeKeyword: (emailId: string, keyword: string, accountId?: string) => callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise, }, + // The composer (permission: compose:open). Opens it filled in; the user + // reviews the message and decides whether to send it. + compose: { + /** + * Open the composer with these fields. On the mail page it opens in + * place; anywhere else the app first goes to the mail page (the + * plugin is reloaded with it). Resolves to 'opened' or 'navigating'. + * At most 5 times a minute. + */ + open: (message: ComposeInput) => callApi('compose.open', [message]) as Promise<'opened' | 'navigating'>, + }, + // Drafts (permission: email:write). + drafts: { + /** + * Save a draft in the Drafts folder, from the user's default sender + * address, without opening the composer. Attachments are blobs the + * plugin uploaded with api.jmap.uploadBlob. Resolves to the draft's + * email id. At most 10 a minute. + */ + create: (draft: DraftInput) => callApi('drafts.create', [draft]) as Promise, + }, // Folders (permission: email:read). mailboxes: { /** Every folder with its role, parent and message counts. */ diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 213b2f2e0..9ca2ffaec 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -686,6 +686,40 @@ export interface VacationInput { toDate?: string | null; } +/** + * A file the plugin uploaded with `api.jmap.uploadBlob`, to attach to a + * message it opens or saves. + */ +export interface BlobAttachmentRef { + blobId: string; + /** 1-255 characters. */ + name: string; + /** A media type such as application/pdf. */ + type: string; + /** Bytes. */ + size: number; +} + +/** + * What a plugin passes to `api.compose.open`. Addresses are + * `a@example.com` or `Name `; at most 100 recipients and 20 + * attachments. + */ +export interface ComposeInput { + to?: string[]; + cc?: string[]; + bcc?: string[]; + /** One line, up to 998 characters. */ + subject?: string; + /** Up to 512 KB. Plain text unless isHtml; HTML is sanitised. */ + body?: string; + isHtml?: boolean; + attachments?: BlobAttachmentRef[]; +} + +/** What a plugin passes to `api.drafts.create`: the same fields as ComposeInput. */ +export type DraftInput = ComposeInput; + export interface FilterRuleView { id: string; name: string; @@ -1199,6 +1233,9 @@ export const ALL_PERMISSIONS = [ 'email:blob-write', // Replace the rendered body of an opened email (render-takeover). 'email:render-takeover', + // Open the composer filled in (api.compose.open). Low risk: the user still + // has to press Send. + 'compose:open', 'calendar:read', 'calendar:write', 'contacts:read', 'contacts:write', 'files:read', 'files:write', diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index 765bd6040..c1667f320 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -6,6 +6,14 @@ export interface ParsedMailto { bcc: string[]; subject: string; body: string; + /** + * Only from a plugin's api.compose.open, never from a mailto: URL: an HTML + * body (sanitised by the host) the composer opens in HTML mode instead of + * `body`. + */ + htmlBody?: string; + /** Only from api.compose.open: files the plugin uploaded, attached as they are. */ + attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; } const MAX_RECIPIENTS = 200; @@ -134,10 +142,19 @@ export function requestInternalMailto(raw: string): boolean { if (typeof window === "undefined") return false; const parsed = parseMailto(raw); if (!parsed || parsed.to.length === 0) return false; + return requestInternalCompose(parsed); +} + +/** + * Ask the mail page to open its composer with this draft, the way an in-app + * mailto: click does. Returns false when no mail page is mounted to take it. + */ +export function requestInternalCompose(draft: ParsedMailto): boolean { + if (typeof window === "undefined") return false; // The listener signals "handled" by cancelling the event, which makes // dispatchEvent report false - so an uncancelled dispatch means nobody took it. const uncancelled = window.dispatchEvent( - new CustomEvent(INTERNAL_MAILTO_EVENT, { detail: parsed, cancelable: true }), + new CustomEvent(INTERNAL_MAILTO_EVENT, { detail: draft, cancelable: true }), ); return !uncancelled; } diff --git a/locales/ar/common.json b/locales/ar/common.json index 69e42e6d4..08bd39d0e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "تغيير الوسوم والعلامات على بريدك", - "body": "إضافة الوسوم والعلامات والكلمات المفتاحية الأخرى إلى رسائلك أو إزالتها." + "body": "إضافة الوسوم والعلامات والكلمات المفتاحية الأخرى إلى رسائلك أو إزالتها، وحفظ المسودات." }, "email_send": { "title": "تغيير البريد قبل إرساله", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "معرفة وقت بدء التطبيق وتوقفه", "body": "تلقّي إشعار عند بدء التطبيق أو انقطاع اتصاله أو تسجيل خروجك." + }, + "compose_open": { + "title": "فتح محرر الرسائل لك", + "body": "فتح رسالة جديدة بعد تعبئة المستلمين أو النص أو المرفقات. يبقى قرار إرسالها لك." } }, "dialog": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 3faa1e74a..a7bf6e941 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Canviar les etiquetes i marques del correu", - "body": "Afegir o treure etiquetes, marques i altres paraules clau dels vostres missatges." + "body": "Afegir o treure etiquetes, marques i altres paraules clau dels vostres missatges, i desar esborranys." }, "email_send": { "title": "Canviar el correu abans d'enviar-lo", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Saber quan l'aplicació s'inicia i s'atura", "body": "Rebre un avís quan l'aplicació s'inicia, es queda sense connexió o tanqueu la sessió." + }, + "compose_open": { + "title": "Obrir el redactor per a vós", + "body": "Obrir un missatge nou amb els destinataris, el text o els adjunts ja emplenats. Sou vós qui decidiu si s'envia." } }, "dialog": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 40930c54e..6f0c44b69 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Měnit štítky a příznaky pošty", - "body": "Přidává nebo odebírá štítky, příznaky a další klíčová slova u vašich zpráv." + "body": "Přidává nebo odebírá štítky, příznaky a další klíčová slova u vašich zpráv a ukládá koncepty." }, "email_send": { "title": "Měnit poštu před odesláním", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Vědět, kdy se aplikace spouští a ukončuje", "body": "Dostává zprávu, když se aplikace spustí, přejde do režimu offline nebo když se odhlásíte." + }, + "compose_open": { + "title": "Otevírat editor zprávy", + "body": "Otevře novou zprávu s vyplněnými příjemci, textem nebo přílohami. O odeslání stále rozhodujete vy." } }, "dialog": { diff --git a/locales/da/common.json b/locales/da/common.json index c0656d684..dfa29fd4e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Ændre tags og markeringer på dine e-mails", - "body": "Tilføje eller fjerne tags, markeringer og andre nøgleord på dine beskeder." + "body": "Tilføje eller fjerne tags, markeringer og andre nøgleord på dine beskeder og gemme kladder." }, "email_send": { "title": "Ændre e-mails, før de sendes", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Vide, når appen starter og stopper", "body": "Få besked, når appen starter, går offline, eller du logger ud." + }, + "compose_open": { + "title": "Åbne komponisten for dig", + "body": "Åbne en ny besked med modtagere, tekst eller vedhæftninger udfyldt. Du bestemmer stadig, om den sendes." } }, "dialog": { diff --git a/locales/de/common.json b/locales/de/common.json index ee55d757a..73aba7ce0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Labels und Markierungen Ihrer E-Mails ändern", - "body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen." + "body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen und Entwürfe speichern." }, "email_send": { "title": "E-Mails vor dem Senden ändern", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Erfahren, wann die App startet und beendet wird", "body": "Benachrichtigt werden, wenn die App startet, offline geht oder Sie sich abmelden." + }, + "compose_open": { + "title": "Den Editor für Sie öffnen", + "body": "Eine neue Nachricht mit bereits ausgefüllten Empfängern, Text oder Anhängen öffnen. Ob sie gesendet wird, entscheiden weiterhin Sie." } }, "dialog": { diff --git a/locales/en/common.json b/locales/en/common.json index 591e34c8a..65a7b4b53 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Change tags and flags on your email", - "body": "Add or remove tags, flags and other keywords on your messages." + "body": "Add or remove tags, flags and other keywords on your messages, and save drafts." }, "email_send": { "title": "Change mail before it is sent", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Know when the app starts and stops", "body": "Be told when the app starts, goes offline or you sign out." + }, + "compose_open": { + "title": "Open the composer for you", + "body": "Open a new message with recipients, text or attachments filled in. You still decide whether to send it." } }, "dialog": { diff --git a/locales/es/common.json b/locales/es/common.json index b1d18d971..7ac46e075 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Cambiar etiquetas y marcas de tu correo", - "body": "Añadir o quitar etiquetas, marcas y otras palabras clave de tus mensajes." + "body": "Añadir o quitar etiquetas, marcas y otras palabras clave de tus mensajes, y guardar borradores." }, "email_send": { "title": "Cambiar el correo antes de enviarlo", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Saber cuándo se inicia y se cierra la aplicación", "body": "Recibir un aviso cuando la aplicación se inicia, se queda sin conexión o cierras sesión." + }, + "compose_open": { + "title": "Abrir el editor por ti", + "body": "Abrir un mensaje nuevo con los destinatarios, el texto o los adjuntos ya rellenados. Tú sigues decidiendo si se envía." } }, "dialog": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 93acc0672..9f5686988 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "تغییر برچسب‌ها و پرچم‌های ایمیل‌های شما", - "body": "افزودن یا حذف برچسب‌ها، پرچم‌ها و کلیدواژه‌های دیگر روی پیام‌های شما." + "body": "افزودن یا حذف برچسب‌ها، پرچم‌ها و کلیدواژه‌های دیگر روی پیام‌های شما، و ذخیره پیش‌نویس‌ها." }, "email_send": { "title": "تغییر ایمیل پیش از ارسال", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "باخبر شدن از شروع و توقف برنامه", "body": "باخبر شدن وقتی برنامه شروع می‌شود، آفلاین می‌شود یا از حساب خارج می‌شوید." + }, + "compose_open": { + "title": "باز کردن ویرایشگر برای شما", + "body": "باز کردن پیامی تازه با گیرندگان، متن یا پیوست‌های ازپیش‌پرشده. تصمیم ارسال همچنان با شماست." } }, "dialog": { diff --git a/locales/fr/common.json b/locales/fr/common.json index d78fb6cf9..c29436d86 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Modifier les étiquettes et indicateurs de vos e-mails", - "body": "Ajouter ou retirer des étiquettes, des indicateurs et d'autres mots-clés sur vos messages." + "body": "Ajouter ou retirer des étiquettes, des indicateurs et d'autres mots-clés sur vos messages, et enregistrer des brouillons." }, "email_send": { "title": "Modifier les e-mails avant leur envoi", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Savoir quand l'application démarre et s'arrête", "body": "Être averti lorsque l'application démarre, passe hors ligne ou que vous vous déconnectez." + }, + "compose_open": { + "title": "Ouvrir l'éditeur pour vous", + "body": "Ouvrir un nouveau message avec les destinataires, le texte ou les pièces jointes déjà remplis. C'est toujours vous qui décidez de l'envoyer." } }, "dialog": { diff --git a/locales/he/common.json b/locales/he/common.json index 1e2daf712..40a968d75 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4039,7 +4039,7 @@ }, "email_write": { "title": "שינוי תגים וסימונים בדואר שלכם", - "body": "הוספה או הסרה של תגים, סימונים ומילות מפתח אחרות בהודעות שלכם." + "body": "הוספה או הסרה של תגים, סימונים ומילות מפתח אחרות בהודעות שלכם, ושמירת טיוטות." }, "email_send": { "title": "שינוי דואר לפני שליחתו", @@ -4244,6 +4244,10 @@ "app_lifecycle": { "title": "לדעת מתי האפליקציה מופעלת ונעצרת", "body": "לקבל עדכון כשהאפליקציה מופעלת, עוברת למצב לא מקוון או כשאתם מתנתקים." + }, + "compose_open": { + "title": "פתיחת העורך עבורכם", + "body": "פתיחת הודעה חדשה עם נמענים, טקסט או קבצים מצורפים שכבר מולאו. ההחלטה אם לשלוח אותה נשארת שלכם." } }, "dialog": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 9cab011e0..e3462daa5 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Címkék és jelölők módosítása a leveleiden", - "body": "Címkéket, jelölőket és más kulcsszavakat ad hozzá az üzeneteidhez, vagy eltávolítja őket." + "body": "Címkéket, jelölőket és más kulcsszavakat ad hozzá az üzeneteidhez, vagy eltávolítja őket, és piszkozatokat ment." }, "email_send": { "title": "Levelek módosítása küldés előtt", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Tudomást szerez az alkalmazás indulásáról és leállásáról", "body": "Értesül, amikor az alkalmazás elindul, offline módba vált, vagy kijelentkezel." + }, + "compose_open": { + "title": "A szerkesztő megnyitása", + "body": "Új üzenetet nyit meg kitöltött címzettekkel, szöveggel vagy mellékletekkel. Az elküldésről továbbra is te döntesz." } }, "dialog": { diff --git a/locales/it/common.json b/locales/it/common.json index 9e9c38246..74c139964 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Modificare etichette e contrassegni della tua posta", - "body": "Aggiungere o rimuovere etichette, contrassegni e altre parole chiave dai tuoi messaggi." + "body": "Aggiungere o rimuovere etichette, contrassegni e altre parole chiave dai tuoi messaggi, e salvare bozze." }, "email_send": { "title": "Modificare la posta prima dell'invio", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Sapere quando l'app si avvia e si chiude", "body": "Ricevere un avviso quando l'app si avvia, va offline o ti disconnetti." + }, + "compose_open": { + "title": "Aprire l'editor per te", + "body": "Aprire un nuovo messaggio con destinatari, testo o allegati già compilati. Sei sempre tu a decidere se inviarlo." } }, "dialog": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 38aaae9ee..9194c7aff 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "メールのタグとフラグを変更", - "body": "メッセージにタグ、フラグ、その他のキーワードを追加したり削除したりできます。" + "body": "メッセージにタグ、フラグ、その他のキーワードを追加したり削除したり、下書きを保存したりできます。" }, "email_send": { "title": "送信前のメールを変更", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "アプリの起動と終了を知る", "body": "アプリが起動したとき、オフラインになったとき、サインアウトしたときに通知を受けます。" + }, + "compose_open": { + "title": "作成画面を開く", + "body": "宛先、本文、添付ファイルを入力した状態で新しいメッセージを開きます。送信するかどうかはあなたが決めます。" } }, "dialog": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 6cd559931..cb07b8611 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "메일의 태그와 플래그 변경", - "body": "메시지에 태그, 플래그 및 기타 키워드를 추가하거나 제거할 수 있어요." + "body": "메시지에 태그, 플래그 및 기타 키워드를 추가하거나 제거하고, 임시보관함에 메일을 저장할 수 있어요." }, "email_send": { "title": "보내기 전에 메일 변경", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "앱 시작과 종료 알림 받기", "body": "앱이 시작되거나, 오프라인이 되거나, 로그아웃할 때 알림을 받아요." + }, + "compose_open": { + "title": "작성 창 열기", + "body": "받는 사람, 본문, 첨부 파일이 채워진 새 메시지를 열어요. 보낼지는 직접 결정해요." } }, "dialog": { diff --git a/locales/lv/common.json b/locales/lv/common.json index deb525349..a672e2bc7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Mainīt vēstuļu tagus un karodziņus", - "body": "Pievienot vai noņemt tagus, karodziņus un citus atslēgvārdus jūsu vēstulēm." + "body": "Pievienot vai noņemt tagus, karodziņus un citus atslēgvārdus jūsu vēstulēm un saglabāt melnrakstus." }, "email_send": { "title": "Mainīt vēstules pirms nosūtīšanas", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Zināt, kad lietotne startē un apstājas", "body": "Saņemt ziņu, kad lietotne startē, pāriet bezsaistē vai jūs izrakstāties." + }, + "compose_open": { + "title": "Atvērt redaktoru jūsu vietā", + "body": "Atvērt jaunu vēstuli ar jau aizpildītiem adresātiem, tekstu vai pielikumiem. Vai to nosūtīt, joprojām izlemjat jūs." } }, "dialog": { diff --git a/locales/mn/common.json b/locales/mn/common.json index fd03bcba9..653eae396 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Имэйлийн таг болон тугийг өөрчлөх", - "body": "Таны имэйлүүдэд таг, туг болон бусад түлхүүр үг нэмэх эсвэл хасах." + "body": "Таны имэйлүүдэд таг, туг болон бусад түлхүүр үг нэмэх эсвэл хасах, мөн ноорог хадгалах." }, "email_send": { "title": "Илгээхээс өмнө имэйлийг өөрчлөх", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Апп эхлэх, зогсохыг мэдэх", "body": "Апп эхлэх, офлайн болох эсвэл таныг гарах үед мэдэгдэл авах." + }, + "compose_open": { + "title": "Имэйл бичих цонх нээх", + "body": "Хүлээн авагч, текст эсвэл хавсралтыг нь бөглөсөн шинэ имэйл нээх. Илгээх эсэхийг та өөрөө шийднэ." } }, "dialog": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 714a734e8..564870a89 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Endre etiketter og flagg på e-posten din", - "body": "Legge til eller fjerne etiketter, flagg og andre nøkkelord på meldingene dine." + "body": "Legge til eller fjerne etiketter, flagg og andre nøkkelord på meldingene dine, og lagre utkast." }, "email_send": { "title": "Endre e-post før den sendes", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Vite når appen starter og stopper", "body": "Få beskjed når appen starter, går frakoblet eller du logger ut." + }, + "compose_open": { + "title": "Åpne meldingsvinduet for deg", + "body": "Åpne en ny melding med mottakere, tekst eller vedlegg fylt ut. Du bestemmer fortsatt om den skal sendes." } }, "dialog": { diff --git a/locales/nl/common.json b/locales/nl/common.json index b1c14453d..c0713f0e8 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Labels en markeringen van je e-mail wijzigen", - "body": "Labels, markeringen en andere trefwoorden aan je berichten toevoegen of ervan verwijderen." + "body": "Labels, markeringen en andere trefwoorden aan je berichten toevoegen of ervan verwijderen, en concepten opslaan." }, "email_send": { "title": "E-mail wijzigen voordat die wordt verzonden", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Weten wanneer de app start en stopt", "body": "Een melding krijgen wanneer de app start, offline gaat of je je afmeldt." + }, + "compose_open": { + "title": "De opsteller voor je openen", + "body": "Een nieuw bericht openen met ontvangers, tekst of bijlagen al ingevuld. Jij beslist nog steeds of het wordt verzonden." } }, "dialog": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 63ac73b11..d0a547cfb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Zmiana etykiet i flag w poczcie", - "body": "Dodaje i usuwa etykiety, flagi i inne słowa kluczowe w Twoich wiadomościach." + "body": "Dodaje i usuwa etykiety, flagi i inne słowa kluczowe w Twoich wiadomościach oraz zapisuje szkice." }, "email_send": { "title": "Zmiana poczty przed wysłaniem", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Informacja o uruchomieniu i zamknięciu aplikacji", "body": "Otrzymuje informację, gdy aplikacja się uruchamia, przechodzi w tryb offline lub gdy się wylogowujesz." + }, + "compose_open": { + "title": "Otwieranie edytora wiadomości", + "body": "Otwiera nową wiadomość z wypełnionymi odbiorcami, treścią lub załącznikami. O wysłaniu nadal decydujesz Ty." } }, "dialog": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 5a961d810..5fa738632 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Alterar etiquetas e marcações dos seus e-mails", - "body": "Adicionar ou remover etiquetas, marcações e outras palavras-chave nas suas mensagens." + "body": "Adicionar ou remover etiquetas, marcações e outras palavras-chave nas suas mensagens, e salvar rascunhos." }, "email_send": { "title": "Alterar e-mails antes do envio", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Saber quando o aplicativo inicia e para", "body": "Ser avisado quando o aplicativo inicia, fica offline ou você sai." + }, + "compose_open": { + "title": "Abrir o editor para você", + "body": "Abrir uma nova mensagem com destinatários, texto ou anexos já preenchidos. Você continua decidindo se ela é enviada." } }, "dialog": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 00931022e..3d5ad23e8 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Modificarea etichetelor și marcajelor e-mailurilor", - "body": "Adaugă sau elimină etichete, marcaje și alte cuvinte cheie din mesajele dvs." + "body": "Adaugă sau elimină etichete, marcaje și alte cuvinte cheie din mesajele dvs. și salvează ciorne." }, "email_send": { "title": "Modificarea e-mailurilor înainte de trimitere", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Aflarea momentului pornirii și opririi aplicației", "body": "Este anunțat când aplicația pornește, trece offline sau vă deconectați." + }, + "compose_open": { + "title": "Deschiderea ferestrei de redactare", + "body": "Deschide un mesaj nou cu destinatarii, textul sau atașamentele deja completate. Dvs. decideți în continuare dacă îl trimiteți." } }, "dialog": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 28c13937c..db900b227 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Менять теги и флажки писем", - "body": "Добавлять и удалять теги, флажки и другие ключевые слова у ваших писем." + "body": "Добавлять и удалять теги, флажки и другие ключевые слова у ваших писем, а также сохранять черновики." }, "email_send": { "title": "Менять письма перед отправкой", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Узнавать о запуске и остановке приложения", "body": "Получать уведомления, когда приложение запускается, теряет связь или вы выходите." + }, + "compose_open": { + "title": "Открывать редактор письма", + "body": "Открывать новое письмо с уже заполненными получателями, текстом или вложениями. Отправлять ли его, по-прежнему решаете вы." } }, "dialog": { diff --git a/locales/sk/common.json b/locales/sk/common.json index b5463d6ae..01931fdb7 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Meniť štítky a príznaky pošty", - "body": "Pridáva alebo odstraňuje štítky, príznaky a ďalšie kľúčové slová pri vašich správach." + "body": "Pridáva alebo odstraňuje štítky, príznaky a ďalšie kľúčové slová pri vašich správach a ukladá koncepty." }, "email_send": { "title": "Meniť poštu pred odoslaním", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Vedieť, kedy sa aplikácia spúšťa a ukončuje", "body": "Dostáva správu, keď sa aplikácia spustí, prejde do režimu offline alebo keď sa odhlásite." + }, + "compose_open": { + "title": "Otvárať editor správy", + "body": "Otvorí novú správu s vyplnenými príjemcami, textom alebo prílohami. O odoslaní stále rozhodujete vy." } }, "dialog": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 69d47d3b0..a87ebd6ba 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "E-postalarınızdaki etiketleri ve bayrakları değiştirme", - "body": "İletilerinize etiket, bayrak ve diğer anahtar sözcükleri ekleme veya kaldırma." + "body": "İletilerinize etiket, bayrak ve diğer anahtar sözcükleri ekleme veya kaldırma ve taslak kaydetme." }, "email_send": { "title": "Gönderilmeden önce iletileri değiştirme", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Uygulamanın başladığını ve durduğunu bilme", "body": "Uygulama başladığında, çevrimdışı olduğunda veya çıkış yaptığınızda haber alma." + }, + "compose_open": { + "title": "İleti düzenleyiciyi açma", + "body": "Alıcıları, metni veya ekleri doldurulmuş yeni bir ileti açma. Gönderip göndermemeye yine siz karar verirsiniz." } }, "dialog": { diff --git a/locales/uk/common.json b/locales/uk/common.json index aeb6cf702..54db0aab7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "Змінювати теги й позначки листів", - "body": "Додавати й прибирати теги, позначки та інші ключові слова у ваших листах." + "body": "Додавати й прибирати теги, позначки та інші ключові слова у ваших листах, а також зберігати чернетки." }, "email_send": { "title": "Змінювати листи перед надсиланням", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "Знати, коли програма запускається й зупиняється", "body": "Отримувати сповіщення, коли програма запускається, втрачає зв'язок або ви виходите." + }, + "compose_open": { + "title": "Відкривати редактор листа", + "body": "Відкривати новий лист із уже заповненими одержувачами, текстом або вкладеннями. Надсилати його чи ні, як і раніше, вирішуєте ви." } }, "dialog": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d3d9157c1..c5fad9cf0 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "變更郵件的標籤和旗標", - "body": "為您的郵件新增或移除標籤、旗標和其他關鍵字。" + "body": "為您的郵件新增或移除標籤、旗標和其他關鍵字,並儲存草稿。" }, "email_send": { "title": "在傳送前變更郵件", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "得知應用程式何時啟動和停止", "body": "在應用程式啟動、離線或您登出時收到通知。" + }, + "compose_open": { + "title": "為您開啟撰寫視窗", + "body": "開啟一封已填好收件者、內文或附件的新郵件。是否傳送仍由您決定。" } }, "dialog": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 303ae4f19..ff2d665ca 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4038,7 +4038,7 @@ }, "email_write": { "title": "更改邮件的标签和旗标", - "body": "为您的邮件添加或移除标签、旗标和其他关键词。" + "body": "为您的邮件添加或移除标签、旗标和其他关键词,并保存草稿。" }, "email_send": { "title": "在发送前更改邮件", @@ -4243,6 +4243,10 @@ "app_lifecycle": { "title": "得知应用何时启动和停止", "body": "在应用启动、离线或您退出登录时收到通知。" + }, + "compose_open": { + "title": "为您打开撰写窗口", + "body": "打开一封已填好收件人、正文或附件的新邮件。是否发送仍由您决定。" } }, "dialog": { From 3a65964d26512c107ae5296f29de5222f6ebb95f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:26:16 +0200 Subject: [PATCH 076/147] feat: let plugins show notifications that name the plugin --- .../plugin-host-compose-drafts.test.ts | 2 +- .../plugin-host-notifications.test.ts | 115 ++++++++++++++++++ lib/plugin-sandbox/host-api-compose.ts | 26 +--- lib/plugin-sandbox/host-api-input.ts | 23 ++++ lib/plugin-sandbox/host-api-notify.ts | 62 ++++++++++ lib/plugin-sandbox/host-api.ts | 5 + lib/plugin-sandbox/runtime.tsx | 18 ++- lib/plugin-types.ts | 19 +++ locales/ar/common.json | 4 + locales/ca/common.json | 4 + locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/mn/common.json | 4 + locales/nb/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + 35 files changed, 350 insertions(+), 28 deletions(-) create mode 100644 lib/__tests__/plugin-host-notifications.test.ts create mode 100644 lib/plugin-sandbox/host-api-notify.ts diff --git a/lib/__tests__/plugin-host-compose-drafts.test.ts b/lib/__tests__/plugin-host-compose-drafts.test.ts index 64616ed15..66d5ad2f5 100644 --- a/lib/__tests__/plugin-host-compose-drafts.test.ts +++ b/lib/__tests__/plugin-host-compose-drafts.test.ts @@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { useIdentityStore } from '@/stores/identity-store'; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from '../protocol-handlers/mailto'; import { consumePendingMailto } from '../protocol-handlers/session'; -import { resetPluginRateLimits } from '../plugin-sandbox/host-api-compose'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; import { ALL_PERMISSIONS } from '../plugin-types'; diff --git a/lib/__tests__/plugin-host-notifications.test.ts b/lib/__tests__/plugin-host-notifications.test.ts new file mode 100644 index 000000000..0d51cd103 --- /dev/null +++ b/lib/__tests__/plugin-host-notifications.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import { ALL_PERMISSIONS } from '../plugin-types'; +import { toastHooks } from '../plugin-hooks'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; +import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; + +const toastInfo = vi.fn(); +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: (...a: unknown[]) => toastInfo(...a), warning: vi.fn() }, +})); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +function plugin(id: string, perms = ['ui:notifications']): InstalledPlugin { + return { + id, name: id === 'rss' ? 'RSS Reader' : 'Other', version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const RSS = plugin('rss'); +const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'notifications.show', args); + +class FakeNotification { + static permission: NotificationPermission = 'granted'; + static shown: FakeNotification[] = []; + onclick: (() => void) | null = null; + close = vi.fn(); + constructor(public title: string, public options: NotificationOptions) { + FakeNotification.shown.push(this); + } +} + +beforeEach(() => { + resetPluginRateLimits(); + toastInfo.mockClear(); + FakeNotification.shown = []; + FakeNotification.permission = 'granted'; + vi.stubGlobal('Notification', FakeNotification); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + toastHooks.onNotificationClick.removePlugin('rss'); + toastHooks.onNotificationClick.removePlugin('other'); +}); + +describe('api.notifications.show', () => { + it('shows a system notification naming the plugin, with a tag scoped to it', async () => { + expect(await call(RSS, { title: '3 new posts', body: 'From Hacker News', tag: 'feed:hn' })).toBe('system'); + const [n] = FakeNotification.shown; + expect(n.title).toBe('3 new posts'); + expect(n.options.body).toBe('From Hacker News\nRSS Reader'); + expect(n.options.tag).toBe('plugin:rss:feed:hn'); + expect(toastInfo).not.toHaveBeenCalled(); + }); + + it('falls back to a toast without notification permission', async () => { + FakeNotification.permission = 'default'; + expect(await call(RSS, { title: 'Synced' })).toBe('toast'); + expect(FakeNotification.shown).toHaveLength(0); + expect(toastInfo).toHaveBeenCalledWith('Synced', { message: 'RSS Reader' }); + }); + + it('falls back to a toast when the browser refuses page notifications', async () => { + vi.stubGlobal('Notification', class extends FakeNotification { + constructor(title: string, options: NotificationOptions) { + super(title, options); + throw new TypeError('Illegal constructor'); + } + }); + expect(await call(RSS, { title: 'Synced', body: 'All feeds' })).toBe('toast'); + expect(toastInfo).toHaveBeenCalledWith('Synced', { message: 'All feeds (RSS Reader)' }); + }); + + it('tells only the sending plugin about a click', async () => { + const mine = vi.fn(); + const theirs = vi.fn(); + toastHooks.onNotificationClick.register('rss', mine); + toastHooks.onNotificationClick.register('other', theirs); + await call(RSS, { title: 'Hi', tag: 'x' }); + FakeNotification.shown[0].onclick?.(); + await Promise.resolve(); + expect(mine).toHaveBeenCalledWith({ tag: 'x' }); + expect(theirs).not.toHaveBeenCalled(); + expect(FakeNotification.shown[0].close).toHaveBeenCalled(); + }); + + it('allows five a minute per plugin', async () => { + for (let i = 0; i < 5; i++) await call(RSS, { title: `n${i}` }); + await expect(call(RSS, { title: 'too many' })).rejects.toThrow(/at most 5 notifications a minute/); + expect(await call(plugin('other'), { title: 'mine' })).toBe('system'); + expect(FakeNotification.shown).toHaveLength(6); + }); + + it('caps title and body, refuses odd tags and needs ui:notifications', async () => { + await expect(call(RSS, { title: 'x'.repeat(121) })).rejects.toThrow(/title must be 1-120 characters/); + await expect(call(RSS, { title: '' })).rejects.toThrow(/title must be 1-120/); + await expect(call(RSS, { title: 'ok', body: 'x'.repeat(301) })).rejects.toThrow(/body must be at most 300/); + await expect(call(RSS, { title: 'ok', tag: 'has space' })).rejects.toThrow(/tag must be/); + await expect(call(RSS, { title: 'ok', icon: 'https://evil.example/x.png' })).rejects.toThrow(/unknown property "icon"/); + await expect(call(plugin('rss', []), { title: 'ok' })).rejects.toThrow(/lacks permission "ui:notifications"/); + expect(FakeNotification.shown).toHaveLength(0); + expect(ALL_PERMISSIONS).toContain('ui:notifications'); + expect(HIGH_RISK_PERMISSIONS.has('ui:notifications')).toBe(false); + }); + + it('keeps a notification to one line', async () => { + await call(RSS, { title: 'Line 1\nLine 2', body: 'a\r\nb' }); + expect(FakeNotification.shown[0].title).toBe('Line 1 Line 2'); + expect(FakeNotification.shown[0].options.body).toBe('a b\nRSS Reader'); + }); +}); diff --git a/lib/plugin-sandbox/host-api-compose.ts b/lib/plugin-sandbox/host-api-compose.ts index c3f77c664..981a6a351 100644 --- a/lib/plugin-sandbox/host-api-compose.ts +++ b/lib/plugin-sandbox/host-api-compose.ts @@ -13,7 +13,8 @@ import { useIdentityStore } from '@/stores/identity-store'; import { sanitizePluginBodyHtml } from '../email-sanitization'; import { htmlToPlainText } from '../html-to-text'; import { - readAddressList, readBlobRefs, readBoolean, readObject, readString, sessionClient, type PluginBlobRef, + readAddressList, readBlobRefs, readBoolean, readObject, readString, sessionClient, withinRate, + type PluginBlobRef, } from './host-api-input'; /** Most recipients (to, cc and bcc together) one call may set. */ @@ -57,29 +58,6 @@ function readMessage(method: string, data: Record): MessageFiel }; } -// ─── Rate limits ────────────────────────────────────────────── - -const recentCalls = new Map(); - -/** True when `pluginId` made fewer than `max` `kind` calls in the last minute; records this one. */ -export function withinRate(kind: string, pluginId: string, max: number, windowMs = 60_000): boolean { - const key = `${kind}\u0000${pluginId}`; - const now = Date.now(); - const recent = (recentCalls.get(key) ?? []).filter((t) => now - t < windowMs); - if (recent.length >= max) { - recentCalls.set(key, recent); - return false; - } - recent.push(now); - recentCalls.set(key, recent); - return true; -} - -/** Forget the rate-limit history (tests). */ -export function resetPluginRateLimits(): void { - recentCalls.clear(); -} - // ─── compose.open ───────────────────────────────────────────── const COMPOSE_PER_MINUTE = 5; diff --git a/lib/plugin-sandbox/host-api-input.ts b/lib/plugin-sandbox/host-api-input.ts index d53603f2f..d76dba55a 100644 --- a/lib/plugin-sandbox/host-api-input.ts +++ b/lib/plugin-sandbox/host-api-input.ts @@ -161,3 +161,26 @@ export function readBlobRefs( }; }); } + +// ─── Rate limits ────────────────────────────────────────────── + +const recentCalls = new Map(); + +/** True when `pluginId` made fewer than `max` `kind` calls in the last minute; records this one. */ +export function withinRate(kind: string, pluginId: string, max: number, windowMs = 60_000): boolean { + const key = `${kind}\u0000${pluginId}`; + const now = Date.now(); + const recent = (recentCalls.get(key) ?? []).filter((t) => now - t < windowMs); + if (recent.length >= max) { + recentCalls.set(key, recent); + return false; + } + recent.push(now); + recentCalls.set(key, recent); + return true; +} + +/** Forget the rate-limit history (tests). */ +export function resetPluginRateLimits(): void { + recentCalls.clear(); +} diff --git a/lib/plugin-sandbox/host-api-notify.ts b/lib/plugin-sandbox/host-api-notify.ts new file mode 100644 index 000000000..a6cf4a6a1 --- /dev/null +++ b/lib/plugin-sandbox/host-api-notify.ts @@ -0,0 +1,62 @@ +// Host side of api.notifications.show: a system notification when the user +// has allowed them for the webmail, otherwise a toast. Every one names the +// plugin that sent it, and a plugin may send only a few a minute. + +import type { InstalledPlugin, NotificationInput } from '../plugin-types'; +import { toast as appToast } from '@/stores/toast-store'; +import { toastHooks } from '../plugin-hooks'; +import { readObject, readString, withinRate } from './host-api-input'; + +export const MAX_NOTIFICATION_TITLE = 120; +export const MAX_NOTIFICATION_BODY = 300; +export const NOTIFICATIONS_PER_MINUTE = 5; +const TAG_RE = /^[A-Za-z0-9._:-]{1,64}$/; + +// eslint-disable-next-line no-control-regex -- line breaks and other control characters become spaces +const oneLine = (text: string) => text.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); + +/** + * Show a notification from a plugin. Resolves to 'system' when the browser + * showed it, 'toast' when the app did. Clicking a system notification + * brings the webmail to the front and fires onNotificationClick for this + * plugin only, with the tag it passed. + */ +export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 'system' | 'toast' { + const method = 'notifications.show'; + const data = readObject(input, method, 'notification', ['title', 'body', 'tag']) as NotificationInput & Record; + const title = oneLine(readString(data.title, method, 'title', MAX_NOTIFICATION_TITLE, { min: 1 })); + if (!title) throw new Error(`${method}: title must not be empty`); + const body = data.body === undefined ? '' : oneLine(readString(data.body, method, 'body', MAX_NOTIFICATION_BODY)); + let tag: string | undefined; + if (data.tag !== undefined) { + if (typeof data.tag !== 'string' || !TAG_RE.test(data.tag)) { + throw new Error(`${method}: tag must be 1-64 letters, digits or . _ : -`); + } + tag = data.tag; + } + if (!withinRate('notifications', plugin.id, NOTIFICATIONS_PER_MINUTE)) { + throw new Error(`${method}: at most ${NOTIFICATIONS_PER_MINUTE} notifications a minute`); + } + + const source = plugin.name || plugin.id; + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + try { + const notification = new Notification(title, { + body: body ? `${body}\n${source}` : source, + // Scoped to the plugin, so one plugin's tag can't replace another's notification. + ...(tag ? { tag: `plugin:${plugin.id}:${tag}` } : {}), + }); + notification.onclick = () => { + try { window.focus(); } catch { /* ignore */ } + notification.close(); + void toastHooks.onNotificationClick.emitTo(plugin.id, { tag: tag ?? '' }); + }; + return 'system'; + } catch { + // Some browsers (Chrome on Android) only allow notifications from a + // service worker; the toast below still tells the user. + } + } + appToast.info(title, { message: body ? `${body} (${source})` : source }); + return 'toast'; +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 9a0666b6e..2bec3f277 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -46,6 +46,7 @@ import { } from './host-api-mail'; import { MAX_PLUGIN_FETCH_BYTES } from './host-api-input'; import { doComposeOpen, doDraftsCreate } from './host-api-compose'; +import { doNotificationsShow } from './host-api-notify'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -227,6 +228,8 @@ export const PERM_PER_METHOD: Readonly> = { 'compose.open': 'compose:open', // Save a draft without opening the composer. Nothing is sent. 'drafts.create': 'email:write', + // A system notification (or a toast), naming the plugin, 5 a minute. + 'notifications.show': 'ui:notifications', }; // ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ────── @@ -1920,6 +1923,8 @@ export async function dispatchApiCall( case 'compose.open': return doComposeOpen(plugin, args[0]); case 'drafts.create': return doDraftsCreate(plugin, args[0]); + case 'notifications.show': return doNotificationsShow(plugin, args[0]); + default: throw new Error(`Unhandled method "${method}"`); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index f8dd275ff..d200a6f65 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -31,9 +31,9 @@ import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, EmailSummary, IdentityPatch, - IdentityView, MailboxView, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, - VacationView, + CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, + EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput, + TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; import { AddressBook, ContactCard } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store'; @@ -380,6 +380,18 @@ function buildPluginApi(manifest: PluginManifest) { get: (fileId:string) => callApi('upfiles.get', [fileId]) as Promise, }, + // Notifications (permission: ui:notifications). + notifications: { + /** + * A system notification when the user allowed them for the webmail, + * otherwise a toast; either way it names this plugin. At most 5 a + * minute. Clicking a system notification brings the webmail to the + * front and fires this plugin's onNotificationClick hook with the tag. + * Resolves to 'system' or 'toast'. + */ + show: (notification: NotificationInput) => + callApi('notifications.show', [notification]) as Promise<'system' | 'toast'>, + }, toast: { success: (m: string) => { void callApi('toast.success', [m]); }, error: (m: string) => { void callApi('toast.error', [m]); }, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 9ca2ffaec..03fc565ef 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -720,6 +720,22 @@ export interface ComposeInput { /** What a plugin passes to `api.drafts.create`: the same fields as ComposeInput. */ export type DraftInput = ComposeInput; +/** + * What a plugin passes to `api.notifications.show`. The plugin's name is + * added to what the user sees, so they know who sent it. + */ +export interface NotificationInput { + /** 1-120 characters, one line. */ + title: string; + /** Up to 300 characters, one line. */ + body?: string; + /** + * 1-64 letters, digits or `. _ : -`. A new notification with the same tag + * replaces the old one, and onNotificationClick hands the tag back. + */ + tag?: string; +} + export interface FilterRuleView { id: string; name: string; @@ -1253,6 +1269,9 @@ export const ALL_PERMISSIONS = [ 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:contact-cryptokeys', 'ui:email-details', 'ui:download-file', + // Show a system notification, or a toast when the user hasn't allowed them + // (api.notifications.show). + 'ui:notifications', // Register native message-list category tabs (Gmail-style inbox tabs). 'ui:message-list-tabs', 'ui:composer-toolbar', 'ui:composer-sidebar', diff --git a/locales/ar/common.json b/locales/ar/common.json index 08bd39d0e..9482db193 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "فتح محرر الرسائل لك", "body": "فتح رسالة جديدة بعد تعبئة المستلمين أو النص أو المرفقات. يبقى قرار إرسالها لك." + }, + "ui_notifications": { + "title": "عرض الإشعارات", + "body": "عرض إشعارات على جهازك، أو داخل التطبيق إذا لم تسمح بها. يذكر كل إشعار اسم الإضافة." } }, "dialog": { diff --git a/locales/ca/common.json b/locales/ca/common.json index a7bf6e941..d377ca52b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Obrir el redactor per a vós", "body": "Obrir un missatge nou amb els destinataris, el text o els adjunts ja emplenats. Sou vós qui decidiu si s'envia." + }, + "ui_notifications": { + "title": "Mostrar notificacions", + "body": "Mostrar notificacions al dispositiu, o dins l'aplicació si no les heu permeses. Cadascuna indica el nom del connector." } }, "dialog": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 6f0c44b69..d68561565 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Otevírat editor zprávy", "body": "Otevře novou zprávu s vyplněnými příjemci, textem nebo přílohami. O odeslání stále rozhodujete vy." + }, + "ui_notifications": { + "title": "Zobrazovat oznámení", + "body": "Zobrazuje oznámení ve vašem zařízení, nebo v aplikaci, pokud jste je nepovolili. Každé uvádí název pluginu." } }, "dialog": { diff --git a/locales/da/common.json b/locales/da/common.json index dfa29fd4e..26497aeb5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Åbne komponisten for dig", "body": "Åbne en ny besked med modtagere, tekst eller vedhæftninger udfyldt. Du bestemmer stadig, om den sendes." + }, + "ui_notifications": { + "title": "Vise notifikationer", + "body": "Vise notifikationer på din enhed eller i appen, hvis du ikke har tilladt dem. Hver af dem nævner pluginets navn." } }, "dialog": { diff --git a/locales/de/common.json b/locales/de/common.json index 73aba7ce0..a07485f43 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Den Editor für Sie öffnen", "body": "Eine neue Nachricht mit bereits ausgefüllten Empfängern, Text oder Anhängen öffnen. Ob sie gesendet wird, entscheiden weiterhin Sie." + }, + "ui_notifications": { + "title": "Benachrichtigungen anzeigen", + "body": "Benachrichtigungen auf Ihrem Gerät anzeigen oder in der App, wenn Sie sie nicht erlaubt haben. Jede nennt den Namen des Plugins." } }, "dialog": { diff --git a/locales/en/common.json b/locales/en/common.json index 65a7b4b53..319d7c0f3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Open the composer for you", "body": "Open a new message with recipients, text or attachments filled in. You still decide whether to send it." + }, + "ui_notifications": { + "title": "Show notifications", + "body": "Show notifications on your device, or in the app if you haven't allowed them. Each one names the plugin." } }, "dialog": { diff --git a/locales/es/common.json b/locales/es/common.json index 7ac46e075..bc46ef3f3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Abrir el editor por ti", "body": "Abrir un mensaje nuevo con los destinatarios, el texto o los adjuntos ya rellenados. Tú sigues decidiendo si se envía." + }, + "ui_notifications": { + "title": "Mostrar notificaciones", + "body": "Mostrar notificaciones en tu dispositivo, o dentro de la aplicación si no las has permitido. Cada una indica el nombre del complemento." } }, "dialog": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 9f5686988..c3950f1b2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "باز کردن ویرایشگر برای شما", "body": "باز کردن پیامی تازه با گیرندگان، متن یا پیوست‌های ازپیش‌پرشده. تصمیم ارسال همچنان با شماست." + }, + "ui_notifications": { + "title": "نمایش اعلان‌ها", + "body": "نمایش اعلان روی دستگاه شما، یا داخل برنامه اگر اجازه اعلان نداده‌اید. هر اعلان نام افزونه را نشان می‌دهد." } }, "dialog": { diff --git a/locales/fr/common.json b/locales/fr/common.json index c29436d86..de655c00b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Ouvrir l'éditeur pour vous", "body": "Ouvrir un nouveau message avec les destinataires, le texte ou les pièces jointes déjà remplis. C'est toujours vous qui décidez de l'envoyer." + }, + "ui_notifications": { + "title": "Afficher des notifications", + "body": "Afficher des notifications sur votre appareil, ou dans l'application si vous ne les avez pas autorisées. Chacune indique le nom du plugin." } }, "dialog": { diff --git a/locales/he/common.json b/locales/he/common.json index 40a968d75..54d776d4f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4248,6 +4248,10 @@ "compose_open": { "title": "פתיחת העורך עבורכם", "body": "פתיחת הודעה חדשה עם נמענים, טקסט או קבצים מצורפים שכבר מולאו. ההחלטה אם לשלוח אותה נשארת שלכם." + }, + "ui_notifications": { + "title": "הצגת התראות", + "body": "הצגת התראות במכשיר שלכם, או בתוך האפליקציה אם לא אישרתם התראות. כל התראה מציינת את שם התוסף." } }, "dialog": { diff --git a/locales/hu/common.json b/locales/hu/common.json index e3462daa5..276a52a00 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "A szerkesztő megnyitása", "body": "Új üzenetet nyit meg kitöltött címzettekkel, szöveggel vagy mellékletekkel. Az elküldésről továbbra is te döntesz." + }, + "ui_notifications": { + "title": "Értesítések megjelenítése", + "body": "Értesítéseket jelenít meg az eszközödön, vagy az alkalmazásban, ha nem engedélyezted őket. Mindegyiken szerepel a bővítmény neve." } }, "dialog": { diff --git a/locales/it/common.json b/locales/it/common.json index 74c139964..0eba98099 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Aprire l'editor per te", "body": "Aprire un nuovo messaggio con destinatari, testo o allegati già compilati. Sei sempre tu a decidere se inviarlo." + }, + "ui_notifications": { + "title": "Mostrare notifiche", + "body": "Mostrare notifiche sul tuo dispositivo, o nell'app se non le hai consentite. Ognuna indica il nome del plugin." } }, "dialog": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 9194c7aff..37265f96d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "作成画面を開く", "body": "宛先、本文、添付ファイルを入力した状態で新しいメッセージを開きます。送信するかどうかはあなたが決めます。" + }, + "ui_notifications": { + "title": "通知を表示", + "body": "デバイスに通知を表示します。通知を許可していない場合はアプリ内に表示します。どの通知にもプラグイン名が入ります。" } }, "dialog": { diff --git a/locales/ko/common.json b/locales/ko/common.json index cb07b8611..bdd65e8fd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "작성 창 열기", "body": "받는 사람, 본문, 첨부 파일이 채워진 새 메시지를 열어요. 보낼지는 직접 결정해요." + }, + "ui_notifications": { + "title": "알림 표시", + "body": "기기에 알림을 표시하고, 알림을 허용하지 않았다면 앱 안에 표시해요. 모든 알림에 플러그인 이름이 나와요." } }, "dialog": { diff --git a/locales/lv/common.json b/locales/lv/common.json index a672e2bc7..75921bd6a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Atvērt redaktoru jūsu vietā", "body": "Atvērt jaunu vēstuli ar jau aizpildītiem adresātiem, tekstu vai pielikumiem. Vai to nosūtīt, joprojām izlemjat jūs." + }, + "ui_notifications": { + "title": "Rādīt paziņojumus", + "body": "Rādīt paziņojumus jūsu ierīcē vai lietotnē, ja neesat tos atļāvis. Katrā ir spraudņa nosaukums." } }, "dialog": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 653eae396..c8551685d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Имэйл бичих цонх нээх", "body": "Хүлээн авагч, текст эсвэл хавсралтыг нь бөглөсөн шинэ имэйл нээх. Илгээх эсэхийг та өөрөө шийднэ." + }, + "ui_notifications": { + "title": "Мэдэгдэл харуулах", + "body": "Таны төхөөрөмж дээр мэдэгдэл харуулах, эсвэл мэдэгдлийг зөвшөөрөөгүй бол аппын дотор харуулах. Мэдэгдэл бүр залгаасын нэрийг заана." } }, "dialog": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 564870a89..9f6de1218 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Åpne meldingsvinduet for deg", "body": "Åpne en ny melding med mottakere, tekst eller vedlegg fylt ut. Du bestemmer fortsatt om den skal sendes." + }, + "ui_notifications": { + "title": "Vise varsler", + "body": "Vise varsler på enheten din, eller i appen hvis du ikke har tillatt dem. Hvert varsel viser navnet på tillegget." } }, "dialog": { diff --git a/locales/nl/common.json b/locales/nl/common.json index c0713f0e8..66673adb5 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "De opsteller voor je openen", "body": "Een nieuw bericht openen met ontvangers, tekst of bijlagen al ingevuld. Jij beslist nog steeds of het wordt verzonden." + }, + "ui_notifications": { + "title": "Meldingen tonen", + "body": "Meldingen op je apparaat tonen, of in de app als je ze niet hebt toegestaan. Elke melding noemt de naam van de plug-in." } }, "dialog": { diff --git a/locales/pl/common.json b/locales/pl/common.json index d0a547cfb..86e118269 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Otwieranie edytora wiadomości", "body": "Otwiera nową wiadomość z wypełnionymi odbiorcami, treścią lub załącznikami. O wysłaniu nadal decydujesz Ty." + }, + "ui_notifications": { + "title": "Wyświetlanie powiadomień", + "body": "Wyświetla powiadomienia na urządzeniu lub w aplikacji, jeśli ich nie zezwolono. Każde podaje nazwę wtyczki." } }, "dialog": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 5fa738632..ad77eacae 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Abrir o editor para você", "body": "Abrir uma nova mensagem com destinatários, texto ou anexos já preenchidos. Você continua decidindo se ela é enviada." + }, + "ui_notifications": { + "title": "Mostrar notificações", + "body": "Mostrar notificações no seu dispositivo, ou dentro do aplicativo se você não as permitiu. Cada uma indica o nome do plugin." } }, "dialog": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 3d5ad23e8..8c7edf869 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Deschiderea ferestrei de redactare", "body": "Deschide un mesaj nou cu destinatarii, textul sau atașamentele deja completate. Dvs. decideți în continuare dacă îl trimiteți." + }, + "ui_notifications": { + "title": "Afișarea notificărilor", + "body": "Afișează notificări pe dispozitiv sau în aplicație, dacă nu le-ați permis. Fiecare indică numele pluginului." } }, "dialog": { diff --git a/locales/ru/common.json b/locales/ru/common.json index db900b227..a4775c649 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Открывать редактор письма", "body": "Открывать новое письмо с уже заполненными получателями, текстом или вложениями. Отправлять ли его, по-прежнему решаете вы." + }, + "ui_notifications": { + "title": "Показывать уведомления", + "body": "Показывать уведомления на вашем устройстве или в приложении, если вы их не разрешили. В каждом указано название плагина." } }, "dialog": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 01931fdb7..17ea6d79a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Otvárať editor správy", "body": "Otvorí novú správu s vyplnenými príjemcami, textom alebo prílohami. O odoslaní stále rozhodujete vy." + }, + "ui_notifications": { + "title": "Zobrazovať upozornenia", + "body": "Zobrazuje upozornenia vo vašom zariadení alebo v aplikácii, ak ste ich nepovolili. Každé uvádza názov doplnku." } }, "dialog": { diff --git a/locales/tr/common.json b/locales/tr/common.json index a87ebd6ba..28fadee40 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "İleti düzenleyiciyi açma", "body": "Alıcıları, metni veya ekleri doldurulmuş yeni bir ileti açma. Gönderip göndermemeye yine siz karar verirsiniz." + }, + "ui_notifications": { + "title": "Bildirim gösterme", + "body": "Cihazınızda bildirim gösterme ya da bildirimlere izin vermediyseniz uygulama içinde gösterme. Her bildirimde eklentinin adı yer alır." } }, "dialog": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 54db0aab7..c75b14075 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "Відкривати редактор листа", "body": "Відкривати новий лист із уже заповненими одержувачами, текстом або вкладеннями. Надсилати його чи ні, як і раніше, вирішуєте ви." + }, + "ui_notifications": { + "title": "Показувати сповіщення", + "body": "Показувати сповіщення на вашому пристрої або в програмі, якщо ви їх не дозволили. У кожному вказано назву плагіна." } }, "dialog": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index c5fad9cf0..d6bceebe3 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "為您開啟撰寫視窗", "body": "開啟一封已填好收件者、內文或附件的新郵件。是否傳送仍由您決定。" + }, + "ui_notifications": { + "title": "顯示通知", + "body": "在您的裝置上顯示通知;如果您未允許通知,則在應用程式內顯示。每則通知都會註明外掛程式名稱。" } }, "dialog": { diff --git a/locales/zh/common.json b/locales/zh/common.json index ff2d665ca..ff602eb11 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4247,6 +4247,10 @@ "compose_open": { "title": "为您打开撰写窗口", "body": "打开一封已填好收件人、正文或附件的新邮件。是否发送仍由您决定。" + }, + "ui_notifications": { + "title": "显示通知", + "body": "在您的设备上显示通知;如果您未允许通知,则在应用内显示。每条通知都会注明插件名称。" } }, "dialog": { From 437bb1d11a1bc4761908be3dbc3cb4f3b06663a3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:28:24 +0200 Subject: [PATCH 077/147] test: require every plugin permission to gate a method, hook or slot --- .../plugin-permission-coverage.test.ts | 85 +++++++++++++++++++ lib/plugin-sandbox/permissions.ts | 4 +- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/plugin-permission-coverage.test.ts diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts new file mode 100644 index 000000000..aa310798d --- /dev/null +++ b/lib/__tests__/plugin-permission-coverage.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; +import { HOOK_CATALOG } from '../plugin-hook-catalog'; +import { SLOT_PERMISSIONS, SLOT_PROPS } from '../plugin-sandbox/permissions'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { PERM_PER_METHOD } from '../plugin-sandbox/host-api'; + +const root = path.resolve(__dirname, '../..'); + +/** + * Gates that live outside the three tables, each with the call that + * enforces it. The test reads the file, so a gate that is removed there + * no longer counts here. + */ +const OTHER_GATES: Record = { + 'ui:keyboard': { file: 'lib/plugin-sandbox/loader.ts', call: "pluginHasPermission(plugin, 'ui:keyboard')" }, +}; + +/** + * Permissions in ALL_PERMISSIONS that nothing checks. A plugin could ask + * for one and the user would be asked to allow it, but it would grant + * nothing. None today; one added without a gate shows up here. + */ +const GATES_NOTHING: string[] = []; + +function gatedPermissions(): Map { + const where = new Map(); + const add = (perm: string | null | undefined, by: string) => { + if (!perm) return; + 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 [slot, perms] of Object.entries(SLOT_PERMISSIONS)) for (const perm of perms) add(perm, `slot ${slot}`); + for (const [slot, props] of Object.entries(SLOT_PROPS)) { + if (props === '*') continue; + for (const [prop, perm] of Object.entries(props)) add(perm, `slot prop ${slot}.${prop}`); + } + for (const perm of IMPLICIT_PERMISSIONS) add(perm, 'implicit'); + for (const [perm, gate] of Object.entries(OTHER_GATES)) { + if (readFileSync(path.join(root, gate.file), 'utf8').includes(gate.call)) add(perm, gate.file); + } + return where; +} + +describe('plugin permissions', () => { + it('every permission gates a method, hook or slot (or is a known gap)', () => { + const gated = gatedPermissions(); + const ungated = ALL_PERMISSIONS.filter((perm) => !gated.has(perm)); + expect(ungated).toEqual(GATES_NOTHING); + }); + + it('the tables only use permissions that exist', () => { + const known = new Set(ALL_PERMISSIONS); + expect([...gatedPermissions().keys()].filter((perm) => !known.has(perm))).toEqual([]); + }); + + it('the new host APIs are behind the permissions they were given', () => { + expect(PERM_PER_METHOD).toMatchObject({ + 'calendar.getEvents': 'calendar:read', + 'calendar.createEvent': 'calendar:write', + 'tasks.list': 'tasks:read', + 'tasks.setComplete': 'tasks:write', + 'templates.list': 'templates:read', + 'templates.create': 'templates:write', + 'identities.update': 'identity:write', + 'vacation.get': 'vacation:read', + 'vacation.set': 'vacation:write', + 'email.get': 'email:read', + 'email.list': 'email:read', + 'mailboxes.list': 'email:read', + 'email.getAttachment': 'email:blob-read', + 'email.getRaw': 'email:blob-read', + 'compose.open': 'compose:open', + 'drafts.create': 'email:write', + 'notifications.show': 'ui:notifications', + }); + }); +}); diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index 5bad2b274..6b25573ec 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -41,7 +41,7 @@ export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): bool * components receive the data of the surface they sit on (the open message, * the draft), so they are gated like hooks. */ -const SLOT_PERMISSIONS: Record = { +export const SLOT_PERMISSIONS: Readonly> = { 'toolbar-actions': ['ui:toolbar'], 'app-top-banner': ['ui:app-top-banner'], 'email-banner': ['ui:email-banner'], @@ -71,7 +71,7 @@ const SLOT_PERMISSIONS: Record = { * data, and stays behind the matching read permission. Props not listed here * are dropped, so a new prop at a call site is withheld until it is added. */ -const SLOT_PROPS: Record | '*'> = { +export const SLOT_PROPS: Readonly | '*'>> = { 'toolbar-actions': {}, 'app-top-banner': { username: 'account:read', serverUrl: 'account:read' }, 'email-banner': { email: 'email:read' }, From 3a83c00e0079e0dff6bbffcc46267d0a2b74336f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:31:43 +0200 Subject: [PATCH 078/147] feat: describe every place a plugin can show UI in one catalogue, with a permission for each area --- lib/__tests__/plugin-slot-catalog.test.ts | 64 ++++++++++++ lib/plugin-sandbox/permission-text.ts | 1 + lib/plugin-sandbox/permissions.ts | 70 ++----------- lib/plugin-sandbox/slot-layout.ts | 13 ++- lib/plugin-slot-catalog.ts | 118 ++++++++++++++++++++++ lib/plugin-types.ts | 32 ++---- locales/en/common.json | 46 +++++++-- 7 files changed, 242 insertions(+), 102 deletions(-) create mode 100644 lib/__tests__/plugin-slot-catalog.test.ts create mode 100644 lib/plugin-slot-catalog.ts diff --git a/lib/__tests__/plugin-slot-catalog.test.ts b/lib/__tests__/plugin-slot-catalog.test.ts new file mode 100644 index 000000000..75f76d686 --- /dev/null +++ b/lib/__tests__/plugin-slot-catalog.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { SLOT_CATALOG, type SlotArea } from '../plugin-slot-catalog'; +import { ALL_PERMISSIONS } from '../plugin-types'; + +const root = path.resolve(__dirname, '../..'); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (['node_modules', '.next', '__tests__'].includes(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...sourceFiles(full)); + else if (/\.tsx?$/.test(entry.name) && !/\.test\./.test(entry.name)) out.push(full); + } + return out; +} + +const sources = ['app', 'components', 'hooks', 'lib'].flatMap((d) => sourceFiles(path.join(root, d))) + .map((f) => fs.readFileSync(f, 'utf8')) + .join('\n'); + +/** A mount: `([ + 'app', 'navigation', 'mail-list', 'reader', 'composer', 'calendar', 'contacts', 'tasks', 'files', 'settings', +]); + +describe('slot catalogue', () => { + it('gates every surface behind permissions the host knows', () => { + const known = new Set(ALL_PERMISSIONS); + const unknown: string[] = []; + for (const [name, spec] of Object.entries(SLOT_CATALOG)) { + for (const perm of spec.permissions) if (!known.has(perm)) unknown.push(`${name}: ${perm}`); + if (spec.props !== '*') { + for (const perm of Object.values(spec.props)) if (perm && !known.has(perm)) unknown.push(`${name} prop: ${perm}`); + } + } + expect(unknown).toEqual([]); + }); + + const areas = [...new Set(Object.values(SLOT_CATALOG).map((s) => s.area))]; + it.each(areas)('mounts every %s surface somewhere in the app', (area) => { + const missing = Object.entries(SLOT_CATALOG) + .filter(([name, spec]) => spec.area === area && !isMounted(name)) + .map(([name]) => name); + if (NOT_YET_MOUNTED.has(area)) { + expect(missing.length, `${area} is fully mounted; remove it from NOT_YET_MOUNTED`).toBeGreaterThan(0); + } else { + expect(missing).toEqual([]); + } + }); +}); diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index 11482525d..7eab76cac 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -19,6 +19,7 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'http:fetch', 'http:post', 'filters:write', + 'http:proxy', ]); /** Locale key segment for a permission: `email:read` → `email_read`. */ diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index 5bad2b274..79a6c7620 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -1,6 +1,7 @@ import type { InstalledPlugin, Permission, SlotName } from '../plugin-types'; import { IMPLICIT_PERMISSIONS } from '../plugin-types'; import { hookSpec } from '../plugin-hook-catalog'; +import { slotSpec } from '../plugin-slot-catalog'; /** * Whether `plugin` may use `perm`: the manifest must declare it, and an @@ -36,70 +37,19 @@ export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): bool // ─── Slots ──────────────────────────────────────────────────── -/** - * Permissions that allow rendering into each slot (any one suffices). Slot - * components receive the data of the surface they sit on (the open message, - * the draft), so they are gated like hooks. - */ -const SLOT_PERMISSIONS: Record = { - 'toolbar-actions': ['ui:toolbar'], - 'app-top-banner': ['ui:app-top-banner'], - 'email-banner': ['ui:email-banner'], - 'email-footer': ['ui:email-footer'], - 'composer-toolbar': ['ui:composer-toolbar'], - 'composer-sidebar': ['ui:composer-sidebar'], - 'composer-sidebar-right': ['ui:composer-sidebar'], - 'composer-attachment-source': ['ui:composer-toolbar', 'ui:composer-sidebar'], - 'sidebar-widget': ['ui:sidebar-widget'], - 'email-detail-sidebar': ['ui:email-details'], - 'email-details-section': ['ui:email-details'], - 'settings-section': ['ui:settings-section'], - 'context-menu-email': ['ui:context-menu'], - 'navigation-rail-bottom': ['ui:navigation-rail'], - 'calendar-event-actions': ['ui:calendar-action'], - 'admin-plugin-page': ['ui:admin-page', 'admin:config'], - 'contact-cryptokeys': ['ui:contact-cryptokeys'], - 'attachment-actions': ['email:read'], - // Only shown inside a dialog the plugin opened through the host API. - 'plugin-dialog': ['ui:observe'], -}; - -/** - * The props each slot may hand its plugin, and the permission each needs - * (null: none beyond the slot's own). A ui:* permission only lets a plugin - * draw somewhere; the message, event, contact or account the slot sits on is - * data, and stays behind the matching read permission. Props not listed here - * are dropped, so a new prop at a call site is withheld until it is added. - */ -const SLOT_PROPS: Record | '*'> = { - 'toolbar-actions': {}, - 'app-top-banner': { username: 'account:read', serverUrl: 'account:read' }, - 'email-banner': { email: 'email:read' }, - 'email-footer': {}, - 'composer-toolbar': {}, - 'composer-sidebar': {}, - 'composer-sidebar-right': {}, - 'composer-attachment-source': { onAttach: null }, - 'sidebar-widget': {}, - 'email-detail-sidebar': { email: 'email:read' }, - 'email-details-section': { email: 'email:read', category: null }, - 'settings-section': {}, - 'context-menu-email': {}, - 'navigation-rail-bottom': {}, - 'calendar-event-actions': { eventData: 'calendar:read', setVirtualLocation: null }, - 'admin-plugin-page': {}, - 'contact-cryptokeys': { cryptoKeys: 'contacts:read' }, - 'attachment-actions': { attachment: null }, - // The plugin chose these props itself when it opened the dialog. - 'plugin-dialog': '*', -}; +// Which permissions let a plugin use each slot, and which props (or click +// context) it may see, come from the slot catalogue. A ui:* permission only +// lets a plugin draw somewhere; the message, event, contact or account the +// slot sits on is data, and stays behind the matching read permission. Props +// not listed in the catalogue are dropped, so a new prop at a call site is +// withheld until the catalogue names it. export function slotPropsFor( plugin: InstalledPlugin, slot: SlotName, props: Record, ): Record { - const allowed = SLOT_PROPS[slot]; + const allowed = slotSpec(slot)?.props; if (allowed === '*') return props; if (!allowed) return {}; const out: Record = {}; @@ -113,8 +63,6 @@ export function slotPropsFor( } export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean { - const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot) - ? SLOT_PERMISSIONS[slot as SlotName] - : null; + const perms = slotSpec(slot)?.permissions ?? null; return !!perms && perms.some((perm) => pluginHasPermission(plugin, perm)); } diff --git a/lib/plugin-sandbox/slot-layout.ts b/lib/plugin-sandbox/slot-layout.ts index 95c45717a..1a33df38e 100644 --- a/lib/plugin-sandbox/slot-layout.ts +++ b/lib/plugin-sandbox/slot-layout.ts @@ -1,6 +1,7 @@ // How slot iframes are sized. Shared by the host (which sizes the iframe) // and the sandbox runtime (which measures the plugin's content). +import { SLOT_CATALOG, type SlotSpec } from '../plugin-slot-catalog'; import type { SlotName } from '../plugin-types'; /** @@ -8,13 +9,11 @@ import type { SlotName } from '../plugin-types'; * the event form's action row). Their iframe takes the plugin content's own * width; everything else fills the width of its container. */ -export const INLINE_SLOTS: ReadonlySet = new Set([ - 'toolbar-actions', - 'composer-toolbar', - 'composer-attachment-source', - 'attachment-actions', - 'calendar-event-actions', -]); +export const INLINE_SLOTS: ReadonlySet = new Set( + (Object.entries(SLOT_CATALOG) as [SlotName, SlotSpec][]) + .filter(([, spec]) => spec.kind === 'frame' && spec.inline) + .map(([name]) => name), +); export function isInlineSlot(slot: SlotName | string | null | undefined): boolean { return !!slot && INLINE_SLOTS.has(slot as SlotName); diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts new file mode 100644 index 000000000..76b544bf5 --- /dev/null +++ b/lib/plugin-slot-catalog.ts @@ -0,0 +1,118 @@ +// Every place a plugin can put UI, in one table. The docs and the tests are +// generated from it, the same way as the hook catalogue (plugin-hook-catalog.ts). +// +// Two kinds of surface: +// - `frame`: the plugin renders its own React component into a sandboxed +// iframe (`exports.slots[name]`). For panels, sections and pages. +// - `action`: the plugin declares buttons or menu entries as data +// (`api.ui.setActions(surface, [...])`) and the host draws them in its own +// style; a click reaches the plugin as `onAction`. For surfaces that repeat +// per row or item, where an iframe each would be far too heavy, and for +// menus that must look like the rest of the app. +// Badges on list rows are data too (the onEmailListItemRender hook). + +import type { Permission } from './plugin-types'; + +export type SlotArea = + | 'app' | 'mail-list' | 'reader' | 'composer' | 'navigation' + | 'calendar' | 'contacts' | 'tasks' | 'files' | 'settings' | 'admin' | 'dialog'; + +export interface SlotSpec { + area: SlotArea; + kind: 'frame' | 'action'; + /** Any one of these lets a plugin use the slot. */ + permissions: readonly Permission[]; + /** Props (frame) or click context (action) and the permission each needs; null = none. */ + props: Record | '*'; + /** Sits in a row of buttons and sizes to its content. */ + inline?: boolean; + /** Where it appears. */ + where: string; +} + +const S = ( + area: SlotArea, + kind: SlotSpec['kind'], + permissions: readonly Permission[], + props: SlotSpec['props'], + where: string, + inline = false, +): SlotSpec => ({ area, kind, permissions, props, where, ...(inline ? { inline } : {}) }); + +export const SLOT_CATALOG = { + // ─── App ─────────────────────────────────────────────────── + 'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read' }, 'a banner across the top of every page'), + 'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog'), + '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'), + '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'], { path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.registerPage)'), + + // ─── Message list ────────────────────────────────────────── + 'message-list-header': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null }, 'above the message list'), + 'message-list-empty': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null, searching: null }, 'in an empty message list, below the "no messages" text'), + 'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read' }, 'hover buttons on a message row, and the row menu on phones', true), + 'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true), + 'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'), + 'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true), + + // ─── Reader ──────────────────────────────────────────────── + 'reader-header': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'at the top of the open message, above the subject'), + 'reader-below-subject': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'right under the subject and sender of the open message'), + 'email-banner': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'a banner above the message body'), + 'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'), + 'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'), + 'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation'), + 'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true), + 'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read' }, 'a side panel next to the open message'), + 'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'), + 'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true), + 'attachment-menu': S('reader', 'action', ['email:read'], { emailId: 'email:read', blobId: 'email:read', name: 'email:read', type: 'email:read', size: 'email:read' }, 'entries in each attachment\'s menu (for example "Save to Nextcloud")'), + + // ─── Composer ────────────────────────────────────────────── + 'composer-toolbar': S('composer', 'frame', ['ui:composer-toolbar'], {}, 'the composer\'s plugin button row', true), + 'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send' }, 'between the subject and the message text'), + 'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send' }, 'below the message text, above the attachments'), + 'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel left of the composer'), + 'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel right of the composer'), + 'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null }, 'the composer\'s attach menu, as a source of files', true), + 'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send' }, 'entries in the menu next to Send'), + 'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], {}, 'entries in the attach menu (for example "From Nextcloud")'), + + // ─── Calendar ────────────────────────────────────────────── + 'calendar-event-actions': S('calendar', 'frame', ['ui:calendar-action'], { eventData: 'calendar:read', setVirtualLocation: null }, 'buttons in the event form', true), + 'calendar-event-detail': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in an event\'s details'), + 'calendar-event-form': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in the form for a new or edited event'), + 'calendar-day-header': S('calendar', 'action', ['ui:calendar-action'], { date: null }, 'a short label under a day\'s name in the day and week views (api.ui.setDayLabels)'), + 'mini-calendar-marks': S('calendar', 'action', ['ui:calendar-action'], { date: null }, 'dots on days in the small month calendar (api.ui.setDayLabels)'), + + // ─── Contacts ────────────────────────────────────────────── + 'contact-cryptokeys': S('contacts', 'frame', ['ui:contact-cryptokeys'], { cryptoKeys: 'contacts:read' }, 'the encryption keys section of a contact'), + 'contact-detail-section': S('contacts', 'frame', ['ui:contacts'], { contact: 'contacts:read' }, 'a section in a contact\'s details'), + 'contact-list-actions': S('contacts', 'action', ['ui:contacts'], { contactIds: 'contacts:read' }, 'buttons in the contact list toolbar', true), + + // ─── 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), + + // ─── Files ───────────────────────────────────────────────── + 'file-actions': S('files', 'action', ['ui:files'], { files: 'files:read' }, 'entries in a file\'s menu and the selection toolbar'), + 'file-preview-section': S('files', 'frame', ['ui:files'], { file: 'files:read' }, 'a section in the file preview'), + + // ─── Settings and admin ──────────────────────────────────── + 'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings'), + 'settings-page': S('settings', 'frame', ['ui:settings-section'], {}, 'a page of its own in Settings, listed under the plugin\'s name'), + 'admin-plugin-page': S('admin', 'frame', ['ui:admin-page', 'admin:config'], {}, 'the plugin\'s page in the admin dashboard'), +} as const satisfies Record; + +export type CatalogSlotName = keyof typeof SLOT_CATALOG; + +export function slotSpec(name: string): SlotSpec | null { + return Object.prototype.hasOwnProperty.call(SLOT_CATALOG, name) + ? SLOT_CATALOG[name as CatalogSlotName] + : null; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 31fa5568e..38c28abe9 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1,4 +1,5 @@ import type { CredentialSpec } from './plugin-secrets/credentials'; +import type { CatalogSlotName } from './plugin-slot-catalog'; // Plugin & Theme system types @@ -293,31 +294,8 @@ export interface InstalledPlugin { // ─── UI Slots ──────────────────────────────────────────────── -export type SlotName = - | 'toolbar-actions' - | 'app-top-banner' - | 'email-banner' - | 'email-footer' - | 'composer-toolbar' - | 'composer-sidebar' - | 'composer-sidebar-right' - | 'sidebar-widget' - | 'email-detail-sidebar' - | 'email-details-section' - | 'settings-section' - | 'context-menu-email' - | 'navigation-rail-bottom' - | 'calendar-event-actions' - | 'admin-plugin-page' - | 'contact-cryptokeys' - | 'attachment-actions' - | 'composer-attachment-source' - // Rendered inside a plugin-requested host dialog (see `ui.openDialog` / - // PluginDialogHost), NOT in-page like every other slot above - this is - // the only slot that renders inside the app-root fixed overlay instead of - // wherever the slot is placed in the page, so it's the one to use for a - // large, genuinely clickable custom UI a small toolbar/row slot can't fit. - | 'plugin-dialog'; +/** Every slot and action surface; see lib/plugin-slot-catalog.ts. */ +export type SlotName = CatalogSlotName; export interface SlotRegistration { pluginId: string; @@ -1141,6 +1119,10 @@ export const ALL_PERMISSIONS = [ 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', 'ui:calendar-action', 'ui:admin-page', + // Surfaces from the slot catalogue (lib/plugin-slot-catalog.ts). + 'ui:message-list', 'ui:contacts', 'ui:tasks', 'ui:files', 'ui:commands', 'ui:app-page', + // Requests that carry an account or API key, added by the server (lib/plugin-secrets). + 'http:proxy', 'admin:config', 'app:lifecycle', ] as const; diff --git a/locales/en/common.json b/locales/en/common.json index 736fec31e..9ca6ae30b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4178,8 +4178,8 @@ "body": "Know which page is open and react to links you click." }, "ui_toolbar": { - "title": "Add buttons to the message toolbar", - "body": "Show its own buttons above an open message." + "title": "Add buttons to toolbars", + "body": "Show its own buttons in the message toolbar, on an open message and in the top bar." }, "ui_app_top_banner": { "title": "Show a banner at the top", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Show notices on messages", - "body": "Show a notice above the text of an open message." + "body": "Show its own notices and summaries above, inside and between the parts of an open message or conversation." }, "ui_email_footer": { "title": "Add content below messages", @@ -4210,16 +4210,16 @@ "body": "Sort your inbox into tabs such as Updates or Promotions." }, "ui_composer_toolbar": { - "title": "Add buttons to the composer", - "body": "Show its own buttons while you write a message." + "title": "Add to the composer", + "body": "Show its own buttons, menu entries and sections while you write a message." }, "ui_composer_sidebar": { "title": "Add a panel to the composer", "body": "Show its own panel next to the message you write." }, "ui_sidebar_widget": { - "title": "Add a box to the sidebar", - "body": "Show its own content in the mail sidebar." + "title": "Add to the sidebar", + "body": "Show its own boxes and sections in the mail sidebar." }, "ui_settings_section": { "title": "Add a settings page", @@ -4238,8 +4238,8 @@ "body": "React to keyboard shortcuts it defines." }, "ui_calendar_action": { - "title": "Add buttons to events", - "body": "Show its own buttons when you create or edit an event." + "title": "Add to the calendar", + "body": "Show its own buttons and sections on events, and short labels and dots on calendar days." }, "ui_admin_page": { "title": "Add an admin page", @@ -4248,6 +4248,34 @@ "app_lifecycle": { "title": "Know when the app starts and stops", "body": "Be told when the app starts, goes offline or you sign out." + }, + "ui_message_list": { + "title": "Add to the message list", + "body": "Show its own content above the message list, and buttons on messages and in the selection toolbar." + }, + "ui_contacts": { + "title": "Add to contacts", + "body": "Show its own sections in a contact's details and buttons in the contact list." + }, + "ui_tasks": { + "title": "Add to tasks", + "body": "Show its own sections in a task's details and buttons in the task list." + }, + "ui_files": { + "title": "Add to files", + "body": "Show its own sections in the file preview and entries in a file's menu." + }, + "ui_commands": { + "title": "Add commands", + "body": "Add its own entries to the command palette (Ctrl+K)." + }, + "ui_app_page": { + "title": "Add pages", + "body": "Add its own pages to the app, each with a button in the navigation bar." + }, + "http_proxy": { + "title": "Use accounts you connect", + "body": "Send requests through Bulwark's server with an account or key that you or your administrator set up. The plugin never sees the password, but it can do anything that account can do on its own server." } }, "dialog": { From 5d9268c92380637669fa1933a05b8dbc17749dfc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:31:43 +0200 Subject: [PATCH 079/147] feat: let plugins add buttons, calendar day labels and pages, and use connected accounts from the sandbox --- app/api/plugin-proxy/route.ts | 4 +- .../plugins/__tests__/plugin-actions.test.tsx | 41 +++ components/plugins/plugin-actions.tsx | 103 ++++++ .../plugin-actions-proxy-api.test.ts | 146 +++++++++ lib/plugin-sandbox/actions.ts | 158 ++++++++++ lib/plugin-sandbox/day-labels.ts | 91 ++++++ lib/plugin-sandbox/host-api.ts | 35 ++- lib/plugin-sandbox/host-bridge.ts | 15 +- lib/plugin-sandbox/loader.ts | 34 +- lib/plugin-sandbox/pages.ts | 82 +++++ lib/plugin-sandbox/protocol.ts | 10 + lib/plugin-sandbox/proxy-client.ts | 292 ++++++++++++++++++ lib/plugin-sandbox/runtime.tsx | 100 ++++++ 13 files changed, 1105 insertions(+), 6 deletions(-) create mode 100644 components/plugins/__tests__/plugin-actions.test.tsx create mode 100644 components/plugins/plugin-actions.tsx create mode 100644 lib/__tests__/plugin-actions-proxy-api.test.ts create mode 100644 lib/plugin-sandbox/actions.ts create mode 100644 lib/plugin-sandbox/day-labels.ts create mode 100644 lib/plugin-sandbox/pages.ts create mode 100644 lib/plugin-sandbox/proxy-client.ts diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index b8578337b..a7d941c4c 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -71,7 +71,9 @@ export async function POST(request: NextRequest) { }); } catch (error) { if (error instanceof ProxyError) { - const headers: Record = { 'cache-control': 'no-store' }; + // Marks the proxy's own refusals, so the plugin can tell them from the + // upstream server's errors. + const headers: Record = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code }; const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter; if (retryAfter) headers['retry-after'] = String(retryAfter); return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers }); diff --git a/components/plugins/__tests__/plugin-actions.test.tsx b/components/plugins/__tests__/plugin-actions.test.tsx new file mode 100644 index 000000000..cfd1ce469 --- /dev/null +++ b/components/plugins/__tests__/plugin-actions.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { PluginActions } from '../plugin-actions'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; + +const dispatch = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); +}); + +describe('PluginActions', () => { + it('renders nothing when no plugin declared actions', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('draws declared buttons and sends the click with the context', async () => { + render(); + act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); }); + + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['e1', 'e2'] }); + }); + + it('shows only actions meant for the folder or file type', () => { + act(() => setPluginActions('p', 'P', 'attachment-menu', [ + { id: 'pdf', label: 'Send PDF', mimeTypes: ['application/pdf'] }, + { id: 'img', label: 'Edit image', mimeTypes: ['image/'] }, + ])); + + render(); + + expect(screen.queryByRole('menuitem', { name: 'Send PDF' })).toBeNull(); + expect(screen.getByRole('menuitem', { name: 'Edit image' })).toBeInTheDocument(); + }); +}); diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx new file mode 100644 index 000000000..7f518bb0c --- /dev/null +++ b/components/plugins/plugin-actions.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useSyncExternalStore, useState } from 'react'; +import { iconForName, Puzzle } from '@/components/icons'; +import { ContextMenuItem } from '@/components/ui/context-menu'; +import { cn } from '@/lib/utils'; +import { + actionApplies, + actionsFor, + runAction, + subscribeActions, + type RegisteredAction, +} from '@/lib/plugin-sandbox/actions'; + +const EMPTY: RegisteredAction[] = []; + +/** The actions plugins declared for a surface, filtered by folder role or MIME type. */ +export function usePluginActions(surface: string, filter: { mailboxRole?: string | null; mimeType?: string | null } = {}) { + const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY); + return all.filter((a) => actionApplies(a, filter)); +} + +interface PluginActionsProps { + /** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */ + surface: string; + /** What the click is about (ids of the message, files, ...). Filtered per plugin before it is sent. */ + context: Record; + /** Buttons in a toolbar row, or entries in a menu. */ + variant?: 'buttons' | 'menu'; + mailboxRole?: string | null; + mimeType?: string | null; + className?: string; + /** Icon-only buttons with the label as tooltip (narrow toolbars). */ + compact?: boolean; + /** Called after a click, e.g. to close the menu the entry sits in. */ + onRan?: () => void; +} + +/** + * Buttons or menu entries plugins declared with api.ui.setActions. The host + * draws them so they match the app; the plugin gets the click. + */ +export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, onRan }: PluginActionsProps) { + const actions = usePluginActions(surface, { mailboxRole, mimeType }); + const [busy, setBusy] = useState(null); + if (actions.length === 0) return null; + + const run = async (action: RegisteredAction) => { + const key = `${action.pluginId}:${action.id}`; + setBusy(key); + try { + onRan?.(); + await runAction(action, surface, context); + } finally { + setBusy((current) => (current === key ? null : current)); + } + }; + + if (variant === 'menu') { + return ( + <> + {actions.map((action) => ( + { void run(action); }} + /> + ))} + + ); + } + + return ( +
+ {actions.map((action) => { + const Icon = iconForName(action.icon) ?? Puzzle; + const key = `${action.pluginId}:${action.id}`; + return ( + + ); + })} +
+ ); +} diff --git a/lib/__tests__/plugin-actions-proxy-api.test.ts b/lib/__tests__/plugin-actions-proxy-api.test.ts new file mode 100644 index 000000000..e44e90071 --- /dev/null +++ b/lib/__tests__/plugin-actions-proxy-api.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; + +const apiFetch = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); +const registryEntries = vi.hoisted(() => [] as unknown[]); +vi.mock('../plugin-sandbox/registry', async (orig) => ({ + ...(await orig()), + all: () => registryEntries, +})); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; +import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions'; +import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels'; +import { allPages, resetPages } from '../plugin-sandbox/pages'; +import type { StreamSink } from '../plugin-sandbox/proxy-client'; +import '../plugin-sandbox/loader'; + +function plugin(overrides: Partial = {}): 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, + ...overrides, + }; +} + +beforeEach(() => { + apiFetch.mockReset(); + resetActions(); + resetDayLabels(); + resetPages(); + registryEntries.length = 0; +}); + +describe('ui.setActions', () => { + it('puts a plugin\'s buttons on a surface it may use', async () => { + await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [ + { id: 'b', label: 'Second', order: 2 }, { id: 'a', label: 'First', order: 1, icon: 'archive' }, + ]]); + + expect(actionsFor('message-row-actions').map((a) => [a.pluginId, a.id])).toEqual([['p1', 'a'], ['p1', 'b']]); + }); + + it('refuses a surface the plugin has no permission for, and frame slots', async () => { + await expect(dispatchApiCall(plugin({ permissions: ['ui:toolbar'] }), 'ui.setActions', ['message-row-actions', []])) + .rejects.toThrow(/may not add actions/); + await expect(dispatchApiCall(plugin({ permissions: ['ui:email-banner'] }), 'ui.setActions', ['email-banner', []])) + .rejects.toThrow(/not a surface that takes actions/); + expect(actionsFor('message-row-actions')).toEqual([]); + }); + + it('checks what it is given', () => { + expect(() => checkActions([{ id: 'x', label: '' }])).toThrow(/label/); + expect(() => checkActions([{ id: 'x', label: 'X' }, { id: 'x', label: 'Y' }])).toThrow(/twice/); + expect(() => checkActions(Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A' })))).toThrow(/At most/); + expect(() => checkActions([{ id: 'x', label: 'X', icon: '' }])).toThrow(/icon/); + }); + + it('sends a click to the plugin with only the context it may see', async () => { + const invokeHook = vi.fn().mockResolvedValue(undefined); + const p = plugin({ permissions: ['ui:message-list'] }); + registryEntries.push({ plugin: p, background: { invokeHook } }); + await dispatchApiCall(p, 'ui.setActions', ['message-row-actions', [{ id: 'task', label: 'Make a task' }]]); + + await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 }); + + // emailId needs email:read, which this plugin lacks. + expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: {} }]); + + p.permissions.push('email:read'); + await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 }); + expect(invokeHook).toHaveBeenLastCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: { emailId: 'e1' } }]); + }); +}); + +describe('day labels and pages', () => { + it('stores labels by date, behind ui:calendar-action', async () => { + await expect(dispatchApiCall(plugin(), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday' }]])).rejects.toThrow(/lacks permission/); + await dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday', dot: true }]]); + + expect(dayLabelsByDate().get('2026-12-24')).toEqual([expect.objectContaining({ text: 'Holiday', dot: true, pluginId: 'p1' })]); + await expect(dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '24.12.2026', text: 'x' }]])).rejects.toThrow(/YYYY-MM-DD/); + }); + + it('registers pages behind ui:app-page', async () => { + await dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'files', title: 'Nextcloud', icon: 'cloud' }]]); + + expect(allPages()).toEqual([expect.objectContaining({ pluginId: 'p1', id: 'files', title: 'Nextcloud' })]); + await expect(dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'A B', title: 'x' }]])).rejects.toThrow(/id/); + }); +}); + +describe('proxy.fetch', () => { + const proxied = plugin({ + permissions: ['http:proxy'], + credentials: { cloud: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } } }, + }); + + it('sends the request to the server proxy and returns the answer', async () => { + apiFetch.mockResolvedValue(new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } })); + + const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/ocs/v2.php/cloud/user', { + method: 'GET', headers: { 'OCS-APIRequest': 'true' }, responseType: 'json', + }]); + + expect(res).toMatchObject({ status: 200, ok: true, body: { ok: 1 } }); + const [url, init] = apiFetch.mock.calls[0]; + expect(url).toBe('/api/plugin-proxy?plugin=p1&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Focs%2Fv2.php%2Fcloud%2Fuser'); + expect(init.headers).toMatchObject({ 'OCS-APIRequest': 'true', 'x-bulwark-method': 'GET' }); + }); + + it('refuses a credential the manifest does not declare, and plugins without http:proxy', async () => { + await expect(dispatchApiCall(proxied, 'proxy.fetch', ['other', 'https://a.example', {}])).rejects.toThrow(/declares no credential/); + await expect(dispatchApiCall({ ...proxied, permissions: [] }, 'proxy.fetch', ['cloud', 'https://a.example', {}])).rejects.toThrow(/lacks permission/); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('tells the plugin when the proxy itself refused', async () => { + apiFetch.mockResolvedValue(new Response('{"error":"Too many requests","code":"rate_limited"}', { + status: 429, headers: { 'content-type': 'application/json', 'x-bulwark-proxy-error': 'rate_limited' }, + })); + + const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', {}]); + + expect(res).toMatchObject({ status: 429, ok: false, proxyError: 'rate_limited', body: 'Too many requests' }); + }); + + it('streams a large answer to the iframe in chunks', async () => { + const encoder = new TextEncoder(); + apiFetch.mockResolvedValue(new Response(new ReadableStream({ + start(c) { c.enqueue(encoder.encode('data: one\n\n')); c.enqueue(encoder.encode('data: two\n\n')); c.close(); }, + }), { status: 200, headers: { 'content-type': 'text/event-stream' } })); + const sent: Parameters[0][] = []; + const sink: StreamSink = { sendStream: (m) => { sent.push(m); } }; + + const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', { responseType: 'stream', streamId: 'abc123' }], { sink }); + await vi.waitFor(() => expect(sent.at(-1)?.type).toBe('stream-end')); + + expect(res).toMatchObject({ status: 200, ok: true }); + const text = sent.filter((m) => m.type === 'stream-chunk').map((m) => new TextDecoder().decode((m as { chunk: ArrayBuffer }).chunk)).join(''); + expect(text).toBe('data: one\n\ndata: two\n\n'); + }); +}); diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts new file mode 100644 index 000000000..0538826e6 --- /dev/null +++ b/lib/plugin-sandbox/actions.ts @@ -0,0 +1,158 @@ +// Buttons and menu entries plugins declare as data (`api.ui.setActions`). +// The host draws them in its own style on the surface named in the slot +// catalogue (kind: 'action'); a click goes back to the plugin with the +// surface's context, filtered by the plugin's permissions. + +import { slotSpec } from '../plugin-slot-catalog'; + +export interface PluginAction { + id: string; + label: string; + /** Name of a host icon (see components/icons); unknown names show none. */ + icon?: string; + /** Lower comes first; default 100. */ + order?: number; + /** Show only for these folder roles (message surfaces), e.g. ["inbox"]. */ + mailboxRoles?: string[]; + /** Show only for attachments or files of these MIME types (prefix match, "image/"). */ + mimeTypes?: string[]; + /** A danger action is drawn in red. */ + danger?: boolean; +} + +export interface RegisteredAction extends PluginAction { + pluginId: string; + pluginName: string; +} + +export const MAX_ACTIONS_PER_SURFACE = 8; +const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i; +const ICON_RE = /^[A-Za-z0-9-]{1,40}$/; +const ROLE_RE = /^[a-z]{1,20}$/; +const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i; + +/** Surfaces that take actions, from the slot catalogue. */ +export function isActionSurface(surface: string): boolean { + return slotSpec(surface)?.kind === 'action'; +} + +/** Check and copy what a plugin sent; throws with a message the plugin sees. */ +export function checkActions(value: unknown): PluginAction[] { + if (!Array.isArray(value)) throw new Error('actions must be a list'); + if (value.length > MAX_ACTIONS_PER_SURFACE) throw new Error(`At most ${MAX_ACTIONS_PER_SURFACE} actions per surface`); + const seen = new Set(); + return value.map((raw, i) => { + if (!raw || typeof raw !== 'object') throw new Error(`actions[${i}] must be an object`); + const a = raw as Record; + if (typeof a.id !== 'string' || !ID_RE.test(a.id)) throw new Error(`actions[${i}].id is invalid`); + if (seen.has(a.id)) throw new Error(`actions[${i}].id is used twice`); + seen.add(a.id); + if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`); + const out: PluginAction = { id: a.id, label: a.label.trim() }; + if (a.icon !== undefined) { + if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`); + out.icon = a.icon; + } + if (a.order !== undefined) { + if (typeof a.order !== 'number' || !Number.isFinite(a.order)) throw new Error(`actions[${i}].order must be a number`); + out.order = a.order; + } + if (a.mailboxRoles !== undefined) { + if (!Array.isArray(a.mailboxRoles) || a.mailboxRoles.length > 10 || !a.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) { + throw new Error(`actions[${i}].mailboxRoles must be a list of folder roles`); + } + out.mailboxRoles = [...a.mailboxRoles] as string[]; + } + if (a.mimeTypes !== undefined) { + if (!Array.isArray(a.mimeTypes) || a.mimeTypes.length > 20 || !a.mimeTypes.every((m) => typeof m === 'string' && MIME_RE.test(m))) { + throw new Error(`actions[${i}].mimeTypes must be a list of MIME types`); + } + out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase()); + } + if (a.danger !== undefined) out.danger = a.danger === true; + return out; + }); +} + +// pluginId → surface → actions +const registry = new Map>(); +const listeners = new Set<() => void>(); +const snapshots = new Map(); + +function changed(): void { + snapshots.clear(); + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribeActions(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setPluginActions(pluginId: string, pluginName: string, surface: string, actions: PluginAction[]): void { + let surfaces = registry.get(pluginId); + if (!surfaces) { + surfaces = new Map(); + registry.set(pluginId, surfaces); + } + if (actions.length === 0) surfaces.delete(surface); + else surfaces.set(surface, actions.map((a) => ({ ...a, pluginId, pluginName }))); + changed(); +} + +/** Drop everything a plugin declared (it was turned off or removed). */ +export function clearPluginActions(pluginId: string): void { + if (registry.delete(pluginId)) changed(); +} + +/** + * The actions on a surface, in order. The same array is returned until + * something changes, so it can feed useSyncExternalStore. + */ +export function actionsFor(surface: string): RegisteredAction[] { + const cached = snapshots.get(surface); + if (cached) return cached; + const out: RegisteredAction[] = []; + for (const surfaces of registry.values()) out.push(...(surfaces.get(surface) ?? [])); + out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label)); + snapshots.set(surface, out); + return out; +} + +/** Whether an action applies to a folder role and/or a MIME type. */ +export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean { + if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false; + if (action.mimeTypes && ctx.mimeType !== undefined) { + const type = (ctx.mimeType ?? '').toLowerCase(); + if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false; + } + return true; +} + +/** Test helper. */ +export function resetActions(): void { + registry.clear(); + changed(); +} + +// ─── Clicks ─────────────────────────────────────────────────── + +/** + * Delivers a click to the plugin. Set by the loader, which owns the running + * plugins; it filters `context` by the surface's props and the plugin's + * permissions before anything crosses into the sandbox. + */ +export type ActionDispatcher = (pluginId: string, surface: string, actionId: string, context: Record) => Promise; + +let dispatcher: ActionDispatcher | null = null; + +export function setActionDispatcher(next: ActionDispatcher | null): void { + dispatcher = next; +} + +export async function runAction(action: RegisteredAction, surface: string, context: Record): Promise { + if (!dispatcher) return; + await dispatcher(action.pluginId, surface, action.id, context); +} diff --git a/lib/plugin-sandbox/day-labels.ts b/lib/plugin-sandbox/day-labels.ts new file mode 100644 index 000000000..761d8dac4 --- /dev/null +++ b/lib/plugin-sandbox/day-labels.ts @@ -0,0 +1,91 @@ +// Short labels and dots plugins put on calendar days (`api.ui.setDayLabels`): +// a holiday name under the day header, a dot in the small month calendar. +// Data only; the host draws them. + +export interface DayLabel { + /** YYYY-MM-DD */ + date: string; + /** Shown under the day's name in the day and week views (max 24 characters). */ + text?: string; + /** Tooltip (max 120 characters). */ + title?: string; + /** A dot on the day in the small month calendar. */ + dot?: boolean; +} + +export interface RegisteredDayLabel extends DayLabel { + pluginId: string; + pluginName: string; +} + +export const MAX_DAY_LABELS = 400; +const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +export function checkDayLabels(value: unknown): DayLabel[] { + if (!Array.isArray(value)) throw new Error('labels must be a list'); + if (value.length > MAX_DAY_LABELS) throw new Error(`At most ${MAX_DAY_LABELS} day labels`); + return value.map((raw, i) => { + if (!raw || typeof raw !== 'object') throw new Error(`labels[${i}] must be an object`); + const l = raw as Record; + if (typeof l.date !== 'string' || !DATE_RE.test(l.date)) throw new Error(`labels[${i}].date must be YYYY-MM-DD`); + const out: DayLabel = { date: l.date }; + if (l.text !== undefined) { + if (typeof l.text !== 'string' || l.text.length > 24) throw new Error(`labels[${i}].text must be at most 24 characters`); + if (l.text.trim()) out.text = l.text.trim(); + } + if (l.title !== undefined) { + if (typeof l.title !== 'string' || l.title.length > 120) throw new Error(`labels[${i}].title must be at most 120 characters`); + if (l.title.trim()) out.title = l.title.trim(); + } + if (l.dot !== undefined) out.dot = l.dot === true; + if (!out.text && !out.dot) throw new Error(`labels[${i}] needs text or dot`); + return out; + }); +} + +const byPlugin = new Map(); +const listeners = new Set<() => void>(); +let snapshot: Map | null = null; + +function changed(): void { + snapshot = null; + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribeDayLabels(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setDayLabels(pluginId: string, pluginName: string, labels: DayLabel[]): void { + if (labels.length === 0) byPlugin.delete(pluginId); + else byPlugin.set(pluginId, labels.map((l) => ({ ...l, pluginId, pluginName }))); + changed(); +} + +export function clearDayLabels(pluginId: string): void { + if (byPlugin.delete(pluginId)) changed(); +} + +/** Labels by date. Stable between changes (for useSyncExternalStore). */ +export function dayLabelsByDate(): Map { + if (snapshot) return snapshot; + const out = new Map(); + for (const labels of byPlugin.values()) { + for (const l of labels) { + const list = out.get(l.date); + if (list) list.push(l); + else out.set(l.date, [l]); + } + } + snapshot = out; + return out; +} + +/** Test helper. */ +export function resetDayLabels(): void { + byPlugin.clear(); + changed(); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index bd0db432d..628e6e704 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -3,7 +3,11 @@ // structured-cloneable data back to the iframe. import type { InstalledPlugin, Permission } from '../plugin-types'; -import { pluginHasPermission } from './permissions'; +import { mayOfferSlot, pluginHasPermission } from './permissions'; +import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client'; +import { checkActions, isActionSurface, setPluginActions } from './actions'; +import { checkDayLabels, setDayLabels } from './day-labels'; +import { checkPages, setPluginPages } from './pages'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; @@ -169,6 +173,17 @@ const PERM_PER_METHOD: Record = { // redirect URI the provider sends the user back to. 'oauth.createState': 'http:fetch', 'oauth.getRedirectUri': 'http:fetch', + // Requests with a credential, through the server (lib/plugin-secrets). + 'proxy.fetch': 'http:proxy', + 'proxy.cancel': 'http:proxy', + 'proxy.connect': 'http:proxy', + 'proxy.accounts': 'http:proxy', + 'proxy.disconnect': 'http:proxy', + // Buttons and menu entries drawn by the host; the surface's own + // permission is checked per call (see the slot catalogue). + 'ui.setActions': null, + 'ui.setDayLabels': 'ui:calendar-action', + 'ui.setPages': 'ui:app-page', // sieve (delivery-time classification) 'sieve.isSupported': 'filters:read', 'sieve.getActiveScript': 'filters:read', @@ -1576,7 +1591,7 @@ export async function dispatchApiCall( plugin: InstalledPlugin, method: string, args: unknown[], - opts?: { privileged?: boolean }, + opts?: { privileged?: boolean; sink?: StreamSink }, ): Promise { // Tier gate: privileged-only methods are refused for untrusted (null-origin) // instances even if the permission is somehow present. Defence-in-depth on @@ -1596,6 +1611,22 @@ export async function dispatchApiCall( } switch (method) { + case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null); + case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined; + case 'proxy.connect': return proxyConnect(plugin, args); + case 'proxy.accounts': return proxyAccounts(plugin); + case 'proxy.disconnect': return proxyDisconnect(plugin, args); + + case 'ui.setActions': { + const surface = args[0]; + if (typeof surface !== 'string' || !isActionSurface(surface)) throw new Error(`"${String(surface)}" is not a surface that takes actions`); + if (!mayOfferSlot(plugin, surface)) throw new Error(`Plugin "${plugin.id}" may not add actions to "${surface}"`); + setPluginActions(plugin.id, plugin.name, surface, checkActions(args[1])); + return undefined; + } + 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 'storage.get': return storageGet(plugin.id, args[0] as string); case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined; case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined; diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 475c9049c..6bd6704be 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -15,7 +15,7 @@ import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - SandboxToHost, HostToSandbox, InitMsg, InitPayload, + SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg, } from './protocol'; // ─── Callback marshalling ──────────────────────────────────── @@ -249,7 +249,7 @@ export class SandboxInstance { const { id, method, args } = msg; void (async () => { try { - const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged }); + const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged, sink: this }); this.send({ type: 'api-response', id, ok: true, result }); } catch (err) { this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) }); @@ -353,6 +353,17 @@ export class SandboxInstance { return p; } + /** Streamed proxy responses and upload progress (see proxy-client.ts). */ + sendStream(msg: StreamChunkMsg | StreamEndMsg | StreamProgressMsg): void { + if (this.destroyed) return; + if (msg.type === 'stream-chunk') { + // Hand the buffer over instead of copying it. + this.iframe.contentWindow?.postMessage(msg, '*', [msg.chunk]); + return; + } + this.send(msg); + } + setLocale(locale: string): void { if (this.destroyed) return; this.send({ type: 'locale-change', locale }); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 791308b74..26adc17a8 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -21,7 +21,13 @@ import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; -import { mayOfferSlot, mayRegisterHook, pluginHasPermission } from './permissions'; +import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions'; +import { clearPluginActions, setActionDispatcher } from './actions'; +import { clearDayLabels } from './day-labels'; +import { clearPluginPages } from './pages'; +import { cancelPluginProxyRequests } from './proxy-client'; +import { cancelConnectFor } from './connect-dialog'; +import type { SlotName } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -182,6 +188,9 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { + const entry = allActiveEntries().find((e) => e.plugin.id === pluginId); + if (!entry) return; + const visible = slotPropsFor(entry.plugin, surface as SlotName, context); + try { + await entry.background.invokeHook('onAction', [{ surface, actionId, context: visible }]); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + } +}); + // ─── Unload ─────────────────────────────────────────────────── export function unloadSandboxedPlugin(pluginId: string): void { @@ -249,6 +274,13 @@ export function unloadSandboxedPlugin(pluginId: string): void { // Drop any message-list category tabs the plugin registered so the strip // disappears (and the inbox unfilters) the moment the plugin is disabled. try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ } + // Same for the buttons, day labels and pages it declared, and anything + // still running through the proxy for it. + clearPluginActions(pluginId); + clearDayLabels(pluginId); + clearPluginPages(pluginId); + cancelPluginProxyRequests(pluginId); + cancelConnectFor(pluginId); try { entry.background.destroy(); } catch { /* ignore */ } cancelPluginDialogs(pluginId); pluginErrorTracker.reset(pluginId); diff --git a/lib/plugin-sandbox/pages.ts b/lib/plugin-sandbox/pages.ts new file mode 100644 index 000000000..fc375661c --- /dev/null +++ b/lib/plugin-sandbox/pages.ts @@ -0,0 +1,82 @@ +// Full pages plugins add to the app (`api.ui.setPages`): each gets an entry +// in the navigation rail and its own route, /apps//, where +// the plugin's `app-page` slot fills the content area. + +export interface PluginPage { + id: string; + title: string; + /** Name of a host icon (see components/icons). */ + icon?: string; +} + +export interface RegisteredPage extends PluginPage { + pluginId: string; + pluginName: string; +} + +export const MAX_PAGES_PER_PLUGIN = 3; +const ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; +const ICON_RE = /^[A-Za-z0-9-]{1,40}$/; + +export function checkPages(value: unknown): PluginPage[] { + if (!Array.isArray(value)) throw new Error('pages must be a list'); + if (value.length > MAX_PAGES_PER_PLUGIN) throw new Error(`At most ${MAX_PAGES_PER_PLUGIN} pages`); + const seen = new Set(); + return value.map((raw, i) => { + if (!raw || typeof raw !== 'object') throw new Error(`pages[${i}] must be an object`); + const p = raw as Record; + if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`pages[${i}].id must be lowercase letters, digits and hyphens`); + if (seen.has(p.id)) throw new Error(`pages[${i}].id is used twice`); + seen.add(p.id); + if (typeof p.title !== 'string' || !p.title.trim() || p.title.length > 40) throw new Error(`pages[${i}].title is required (max 40 characters)`); + const out: PluginPage = { id: p.id, title: p.title.trim() }; + if (p.icon !== undefined) { + if (typeof p.icon !== 'string' || !ICON_RE.test(p.icon)) throw new Error(`pages[${i}].icon is invalid`); + out.icon = p.icon; + } + return out; + }); +} + +const byPlugin = new Map(); +const listeners = new Set<() => void>(); +let snapshot: RegisteredPage[] | null = null; + +function changed(): void { + snapshot = null; + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribePages(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setPluginPages(pluginId: string, pluginName: string, pages: PluginPage[]): void { + if (pages.length === 0) byPlugin.delete(pluginId); + else byPlugin.set(pluginId, pages.map((p) => ({ ...p, pluginId, pluginName }))); + changed(); +} + +export function clearPluginPages(pluginId: string): void { + if (byPlugin.delete(pluginId)) changed(); +} + +/** Every page, plugins in install order. Stable between changes. */ +export function allPages(): RegisteredPage[] { + if (snapshot) return snapshot; + snapshot = [...byPlugin.values()].flat(); + return snapshot; +} + +export function findPage(pluginId: string, pageId: string): RegisteredPage | null { + return byPlugin.get(pluginId)?.find((p) => p.id === pageId) ?? null; +} + +/** Test helper. */ +export function resetPages(): void { + byPlugin.clear(); + changed(); +} diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index fda559e9a..fce78e31c 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -191,8 +191,18 @@ export interface SlotShouldShowMsg { context: unknown; } +/** Host → sandbox: a piece of a streamed api.proxy.fetch response. */ +export interface StreamChunkMsg { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer; } +/** Host → sandbox: a streamed response ended (error set when it broke off). */ +export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: string; } +/** Host → sandbox: upload progress of an api.proxy.fetch request. */ +export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; } + export type HostToSandbox = | InitMsg + | StreamChunkMsg + | StreamEndMsg + | StreamProgressMsg | ApiResponseMsg | CallbackResponseMsg | HookInvokeMsg diff --git a/lib/plugin-sandbox/proxy-client.ts b/lib/plugin-sandbox/proxy-client.ts new file mode 100644 index 000000000..2ab623782 --- /dev/null +++ b/lib/plugin-sandbox/proxy-client.ts @@ -0,0 +1,292 @@ +// Host side of `api.proxy`: a plugin's requests that need one of its +// credentials go to /api/plugin-proxy, which adds the secret on the server. +// Responses come back whole (text, JSON, bytes) or as a stream of chunks for +// large downloads and AI answers; uploads report progress. + +import type { InstalledPlugin } from '../plugin-types'; +import { apiFetch, withBasePath } from '../browser-navigation'; +import { requestConnect, forgetCredentials, listConnectedAccounts } from './connect-dialog'; + +/** Where streamed chunks and upload progress go: the iframe that asked. */ +export interface StreamSink { + sendStream(msg: + | { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer } + | { type: 'stream-end'; streamId: string; error?: string } + | { type: 'stream-progress'; streamId: string; loaded: number; total: number }): void; +} + +/** Largest response returned in one piece; bigger ones need responseType "stream". */ +export const MAX_PROXY_BUFFERED_BYTES = 25 * 1024 * 1024; +/** Largest request body; matches MAX_PROXY_REQUEST_BYTES on the server. */ +export const MAX_PROXY_UPLOAD_BYTES = 100 * 1024 * 1024; +export const MAX_STREAMS_PER_PLUGIN = 8; + +type ResponseType = 'text' | 'json' | 'arraybuffer' | 'stream'; + +export interface ProxyInit { + method?: string; + headers?: Record; + body?: string | ArrayBuffer | Uint8Array | Blob | null; + responseType?: ResponseType; + /** Set by the runtime: ties chunks and progress to this request. */ + streamId?: string; + /** Store these JSON fields of the response as the credential (see the proxy route). */ + capture?: { secret: string; username?: string }; +} + +export interface ProxyResult { + status: number; + ok: boolean; + headers: Record; + body?: unknown; + /** Set when the proxy itself refused, e.g. "rate_limited", "wrong_origin". */ + proxyError?: string; +} + +const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/; +const STREAM_ID_RE = /^[a-z0-9]{6,40}$/; +const running = new Map>(); + +function credentialSpec(plugin: InstalledPlugin, credentialId: unknown) { + if (typeof credentialId !== 'string') throw new Error('credentialId must be a string'); + const spec = plugin.credentials?.[credentialId]; + if (!spec) throw new Error(`The manifest declares no credential "${credentialId}"`); + return { id: credentialId, spec }; +} + +function checkInit(raw: unknown): ProxyInit { + if (raw === undefined || raw === null) return {}; + if (typeof raw !== 'object') throw new Error('init must be an object'); + const init = raw as Record; + const out: ProxyInit = {}; + if (init.method !== undefined) { + if (typeof init.method !== 'string' || !/^[A-Z]{3,10}$/i.test(init.method)) throw new Error('Invalid method'); + out.method = init.method.toUpperCase(); + } + if (init.headers !== undefined) { + if (!init.headers || typeof init.headers !== 'object') throw new Error('headers must be an object'); + const entries = Object.entries(init.headers as Record); + if (entries.length > 50) throw new Error('Too many headers'); + out.headers = {}; + for (const [name, value] of entries) { + if (!HEADER_NAME_RE.test(name) || typeof value !== 'string' || value.length > 8192 || /[\r\n]/.test(value)) { + throw new Error(`Invalid header "${name}"`); + } + out.headers[name] = value; + } + } + if (init.body !== undefined && init.body !== null) { + const b = init.body; + const size = typeof b === 'string' ? b.length : b instanceof Blob ? b.size : (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) ? (b as ArrayBuffer).byteLength : -1; + if (size < 0) throw new Error('body must be a string, ArrayBuffer, Uint8Array or Blob'); + if (size > MAX_PROXY_UPLOAD_BYTES) throw new Error(`body is larger than ${MAX_PROXY_UPLOAD_BYTES} bytes; upload it in chunks`); + out.body = b as ProxyInit['body']; + } + if (init.responseType !== undefined) { + if (!['text', 'json', 'arraybuffer', 'stream'].includes(init.responseType as string)) throw new Error('Invalid responseType'); + out.responseType = init.responseType as ResponseType; + } + if (init.streamId !== undefined) { + if (typeof init.streamId !== 'string' || !STREAM_ID_RE.test(init.streamId)) throw new Error('Invalid streamId'); + out.streamId = init.streamId; + } + if (init.capture !== undefined) { + const c = init.capture as Record; + if (!c || typeof c.secret !== 'string' || (c.username !== undefined && typeof c.username !== 'string')) throw new Error('Invalid capture'); + out.capture = { secret: c.secret, ...(typeof c.username === 'string' ? { username: c.username } : {}) }; + } + return out; +} + +function headersOf(res: { headers: Headers }): Record { + const out: Record = {}; + res.headers.forEach((value, name) => { out[name] = value; }); + return out; +} + +async function readCapped(res: Response, limit: number): Promise { + const length = Number(res.headers.get('content-length') ?? '0'); + if (length > limit) { + await res.body?.cancel().catch(() => {}); + throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`); + } + if (!res.body) return new ArrayBuffer(0); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel().catch(() => {}); + throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { out.set(c, offset); offset += c.byteLength; } + return out.buffer; +} + +function track(pluginId: string, streamId: string, controller: AbortController): () => void { + let streams = running.get(pluginId); + if (!streams) { + streams = new Map(); + running.set(pluginId, streams); + } + if (streams.size >= MAX_STREAMS_PER_PLUGIN) throw new Error(`At most ${MAX_STREAMS_PER_PLUGIN} proxy requests at once`); + streams.set(streamId, controller); + return () => { + streams!.delete(streamId); + if (streams!.size === 0) running.delete(pluginId); + }; +} + +/** Upload with progress reports; resolves with a fetch-like Response. */ +function sendWithProgress(url: string, method: string, headers: Record, body: XMLHttpRequestBodyInit, signal: AbortSignal, onProgress: (loaded: number, total: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.responseType = 'arraybuffer'; + for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value); + xhr.setRequestHeader('x-bulwark-method', method); + xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0); + xhr.onload = () => { + const h = new Headers(); + for (const line of xhr.getAllResponseHeaders().trim().split(/[\r\n]+/)) { + const i = line.indexOf(':'); + if (i > 0) h.append(line.slice(0, i).trim(), line.slice(i + 1).trim()); + } + resolve(new Response(xhr.status === 204 || xhr.status === 304 ? null : xhr.response as ArrayBuffer, { status: xhr.status, headers: h })); + }; + xhr.onerror = () => reject(new Error('Network error')); + xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', () => xhr.abort(), { once: true }); + xhr.send(body); + }); +} + +/** `api.proxy.fetch(credentialId, url, init)`. */ +export async function proxyFetch(plugin: InstalledPlugin, args: unknown[], sink: StreamSink | null): Promise { + const { id: credentialId } = credentialSpec(plugin, args[0]); + if (typeof args[1] !== 'string' || args[1].length > 8192) throw new Error('url must be a string'); + const target = args[1]; + const init = checkInit(args[2]); + const method = init.method ?? (init.body ? 'POST' : 'GET'); + const responseType = init.responseType ?? 'text'; + const streamId = init.streamId ?? Math.random().toString(36).slice(2, 12); + if (init.capture && plugin.credentials?.[credentialId]?.scope !== 'user') throw new Error('capture needs a user credential'); + + const query = new URLSearchParams({ plugin: plugin.id, credential: credentialId, url: target }); + const url = withBasePath(`/api/plugin-proxy?${query}`); + const headers: Record = { ...(init.headers ?? {}) }; + if (init.capture) headers['x-bulwark-capture'] = JSON.stringify(init.capture); + + const controller = new AbortController(); + const untrack = track(plugin.id, streamId, controller); + let streaming = false; + try { + let res: Response; + if (init.body && sink && typeof XMLHttpRequest !== 'undefined') { + res = await sendWithProgress(url, method, headers, init.body as XMLHttpRequestBodyInit, controller.signal, + (loaded, total) => sink.sendStream({ type: 'stream-progress', streamId, loaded, total })); + } else { + res = await apiFetch(`/api/plugin-proxy?${query}`, { + method: 'POST', + headers: { ...headers, 'x-bulwark-method': method }, + body: (init.body ?? undefined) as BodyInit | undefined, + signal: controller.signal, + }); + } + + const result: ProxyResult = { status: res.status, ok: res.ok, headers: headersOf(res) }; + const proxyRefused = !!res.headers.get('x-bulwark-proxy-error'); + + if (responseType === 'stream' && sink && res.body && res.ok) { + streaming = true; + const reader = res.body.getReader(); + void (async () => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const copy = value.slice().buffer; + sink.sendStream({ type: 'stream-chunk', streamId, chunk: copy }); + } + sink.sendStream({ type: 'stream-end', streamId }); + } catch (err) { + sink.sendStream({ type: 'stream-end', streamId, error: controller.signal.aborted ? 'cancelled' : (err as Error).message }); + } finally { + untrack(); + } + })(); + return result; + } + + const bytes = await readCapped(res, MAX_PROXY_BUFFERED_BYTES); + if (proxyRefused) { + result.proxyError = res.headers.get('x-bulwark-proxy-error') ?? 'refused'; + try { + result.body = (JSON.parse(new TextDecoder().decode(bytes)) as { error?: string }).error ?? ''; + } catch { + result.body = ''; + } + return result; + } + if (responseType === 'arraybuffer') result.body = bytes; + else { + const text = new TextDecoder().decode(bytes); + if (responseType === 'json') { + try { result.body = text ? JSON.parse(text) : null; } catch { throw new Error(`Response is not JSON (HTTP ${res.status})`); } + } else { + result.body = text; + } + } + return result; + } finally { + if (!streaming) untrack(); + } +} + +/** `api.proxy.cancel(streamId)`: stop a running request or stream. */ +export function proxyCancel(plugin: InstalledPlugin, streamId: unknown): void { + if (typeof streamId !== 'string') return; + running.get(plugin.id)?.get(streamId)?.abort(); +} + +/** Stop everything a plugin has running (it was turned off). */ +export function cancelPluginProxyRequests(pluginId: string): void { + for (const controller of running.get(pluginId)?.values() ?? []) controller.abort(); + running.delete(pluginId); +} + +/** `api.proxy.connect(credentialId, { mode, server, username })`. */ +export async function proxyConnect(plugin: InstalledPlugin, args: unknown[]): Promise<{ connected: boolean; origin?: string }> { + const { id, spec } = credentialSpec(plugin, args[0]); + if (spec.scope !== 'user') throw new Error(`"${id}" is set up by the administrator`); + const opts = (args[1] && typeof args[1] === 'object' ? args[1] : {}) as Record; + const mode = opts.mode === 'signin' ? 'signin' : 'password'; + return requestConnect({ + pluginId: plugin.id, + pluginName: plugin.name, + credentialId: id, + spec, + mode, + ...(typeof opts.server === 'string' && opts.server.length < 2048 ? { suggestedOrigin: opts.server } : {}), + ...(typeof opts.username === 'string' && opts.username.length < 256 ? { suggestedUsername: opts.username } : {}), + }); +} + +/** `api.proxy.accounts()`: which of its credentials are connected, without secrets. */ +export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; origin: string; username?: string; connected: boolean }[]> { + const list = await listConnectedAccounts(plugin.id); + return list.map(({ credentialId, origin, username, connected }) => ({ credentialId, origin, ...(username ? { username } : {}), connected })); +} + +/** `api.proxy.disconnect(credentialId)`. */ +export async function proxyDisconnect(plugin: InstalledPlugin, args: unknown[]): Promise { + const { id } = credentialSpec(plugin, args[0]); + return forgetCredentials(plugin.id, id); +} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 557c896fa..4067e4380 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -190,6 +190,61 @@ function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_AP }); } +// ─── api.proxy streams ─────────────────────────────────────── + +interface ProxyStream { + controller?: ReadableStreamDefaultController; + onProgress?: (loaded: number, total: number) => void; +} +const proxyStreams = new Map(); + +interface ProxyFetchInit { + method?: string; + headers?: Record; + body?: string | ArrayBuffer | Uint8Array | Blob | null; + /** "text" (default), "json", "arraybuffer", or "stream" for a ReadableStream body. */ + responseType?: 'text' | 'json' | 'arraybuffer' | 'stream'; + /** Upload progress, in bytes. */ + onProgress?: (loaded: number, total: number) => void; + /** Store these JSON fields of the response as the credential instead of returning them. */ + capture?: { secret: string; username?: string }; + /** Stops the request (and a streamed body). */ + signal?: AbortSignal; +} + +async function proxyFetch(credentialId: string, url: string, init: ProxyFetchInit = {}): Promise { + const streamId = uid().replace(/[^a-z0-9]/g, '').slice(0, 24); + const entry: ProxyStream = { onProgress: init.onProgress }; + proxyStreams.set(streamId, entry); + const stream = init.responseType === 'stream' + ? new ReadableStream({ + start(controller) { entry.controller = controller; }, + cancel() { + proxyStreams.delete(streamId); + void callApi('proxy.cancel', [streamId]).catch(() => {}); + }, + }) + : null; + const abort = () => { + void callApi('proxy.cancel', [streamId]).catch(() => {}); + try { entry.controller?.error(new Error('cancelled')); } catch { /* closed */ } + proxyStreams.delete(streamId); + }; + init.signal?.addEventListener('abort', abort, { once: true }); + const { onProgress: _p, signal: _s, ...plain } = init; + void _p; void _s; + try { + // No overall timeout: a big upload or a long AI answer takes as long as + // it takes, and api.proxy.cancel / the signal stop it. + const res = await callApi('proxy.fetch', [credentialId, url, { ...plain, streamId }], 0) as Record; + if (!stream || !res.ok) proxyStreams.delete(streamId); + return stream && res.ok ? { ...res, body: stream } : res; + } catch (err) { + proxyStreams.delete(streamId); + throw err; + } +} + function invokeHostCallback(callbackId: string, args: unknown[]): Promise { const id = uid(); return new Promise((resolve, reject) => { @@ -421,6 +476,31 @@ function buildPluginApi(manifest: PluginManifest) { /** Downloads a file generated by the plugin. Not a user's file or attachment. */ downloadFile: (opts: { content: string; filename: string; contentType?: string }) => callApi('ui.downloadFile', [opts]) as Promise, + /** + * Buttons or menu entries on an action surface (see the slot catalogue: + * message rows, the selection toolbar, attachment menus, the command + * palette, ...). Replaces what this plugin set there before; [] removes + * them. Clicks arrive in the `onAction({ surface, actionId, context })` hook. + */ + setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) => + callApi('ui.setActions', [surface, actions]) as Promise, + /** Short labels and dots on calendar days. Replaces this plugin's earlier labels. */ + setDayLabels: (labels: Array<{ date: string; text?: string; title?: string; dot?: boolean }>) => + callApi('ui.setDayLabels', [labels]) as Promise, + /** Full pages with a button in the navigation bar; the `app-page` slot renders them. */ + setPages: (pages: Array<{ id: string; title: string; icon?: string }>) => + callApi('ui.setPages', [pages]) as Promise, + }, + // Requests with an account or key the manifest declares under + // `credentials`; the server adds the secret (permission http:proxy). + proxy: { + fetch: proxyFetch, + /** Show the host's dialog to connect a user credential. */ + connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) => + callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>, + /** Which credentials are connected (no secrets). */ + accounts: () => callApi('proxy.accounts', []) as Promise>, + disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise, }, // Email keyword mutations (permission: email:write). Keywords follow JMAP // syntax, e.g. '$category-promotions' or '$label:'. @@ -840,6 +920,26 @@ function handleHostMessage(ev: MessageEvent): void { case 'props-update': slotPropsUpdater?.(msg.props ?? {}); break; + + case 'stream-chunk': { + const entry = proxyStreams.get(msg.streamId); + try { entry?.controller?.enqueue(new Uint8Array(msg.chunk)); } catch { /* cancelled */ } + break; + } + + case 'stream-end': { + const entry = proxyStreams.get(msg.streamId); + proxyStreams.delete(msg.streamId); + try { + if (msg.error) entry?.controller?.error(new Error(msg.error)); + else entry?.controller?.close(); + } catch { /* already closed */ } + break; + } + + case 'stream-progress': + try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ } + break; } } From 8ee97f23e35376284c78deb16e2de39491dd395e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:34:48 +0200 Subject: [PATCH 080/147] feat: translate the new plugin permissions into every language --- locales/ar/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/ca/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/cs/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/da/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/de/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/es/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/fa/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/fr/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/he/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/hu/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/it/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/ja/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/ko/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/lv/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/mn/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/nb/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/nl/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/pl/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/pt/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/ro/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/ru/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/sk/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/tr/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/uk/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/zh-TW/common.json | 46 +++++++++++++++++++++++++++++++-------- locales/zh/common.json | 46 +++++++++++++++++++++++++++++++-------- 26 files changed, 962 insertions(+), 234 deletions(-) diff --git a/locales/ar/common.json b/locales/ar/common.json index 8925cdc79..e1e898aa5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4178,8 +4178,8 @@ "body": "معرفة الصفحة المفتوحة والتفاعل مع الروابط التي تنقر عليها." }, "ui_toolbar": { - "title": "إضافة أزرار إلى شريط أدوات الرسالة", - "body": "عرض أزرارها الخاصة فوق الرسالة المفتوحة." + "title": "إضافة أزرار إلى أشرطة الأدوات", + "body": "عرض أزرارها الخاصة في شريط أدوات الرسائل، وعلى الرسالة المفتوحة، وفي الشريط العلوي." }, "ui_app_top_banner": { "title": "عرض شريط في الأعلى", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "عرض إشعارات على الرسائل", - "body": "عرض إشعار فوق نص الرسالة المفتوحة." + "body": "عرض إشعاراتها وملخصاتها الخاصة فوق أجزاء الرسالة أو المحادثة المفتوحة وداخلها وبينها." }, "ui_email_footer": { "title": "إضافة محتوى أسفل الرسائل", @@ -4210,16 +4210,16 @@ "body": "فرز البريد الوارد في علامات تبويب مثل التحديثات أو العروض الترويجية." }, "ui_composer_toolbar": { - "title": "إضافة أزرار إلى محرر الرسائل", - "body": "عرض أزرارها الخاصة أثناء كتابة رسالة." + "title": "الإضافة إلى محرر الرسائل", + "body": "عرض أزرارها وعناصر القوائم والأقسام الخاصة بها أثناء كتابة رسالة." }, "ui_composer_sidebar": { "title": "إضافة لوحة إلى محرر الرسائل", "body": "عرض لوحتها الخاصة بجانب الرسالة التي تكتبها." }, "ui_sidebar_widget": { - "title": "إضافة مربع إلى الشريط الجانبي", - "body": "عرض محتواها الخاص في الشريط الجانبي للبريد." + "title": "الإضافة إلى الشريط الجانبي", + "body": "عرض مربعاتها وأقسامها الخاصة في الشريط الجانبي للبريد." }, "ui_settings_section": { "title": "إضافة صفحة إعدادات", @@ -4238,8 +4238,8 @@ "body": "الاستجابة لاختصارات لوحة المفاتيح التي تحددها." }, "ui_calendar_action": { - "title": "إضافة أزرار إلى الأحداث", - "body": "عرض أزرارها الخاصة عند إنشاء حدث أو تحريره." + "title": "الإضافة إلى واجهة التقويم", + "body": "عرض أزرارها وأقسامها الخاصة على الأحداث، وتسميات قصيرة ونقاط على أيام التقويم." }, "ui_admin_page": { "title": "إضافة صفحة إدارة", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "عرض الإشعارات", "body": "عرض إشعارات على جهازك، أو داخل التطبيق إذا لم تسمح بها. يذكر كل إشعار اسم الإضافة." + }, + "ui_message_list": { + "title": "الإضافة إلى قائمة الرسائل", + "body": "عرض محتواها الخاص فوق قائمة الرسائل، وأزرار على الرسائل وفي شريط أدوات الرسائل المحددة." + }, + "ui_contacts": { + "title": "الإضافة إلى واجهة جهات الاتصال", + "body": "عرض أقسامها الخاصة في تفاصيل جهة الاتصال، وأزرار في قائمة جهات الاتصال." + }, + "ui_tasks": { + "title": "الإضافة إلى واجهة المهام", + "body": "عرض أقسامها الخاصة في تفاصيل المهمة، وأزرار في قائمة المهام." + }, + "ui_files": { + "title": "الإضافة إلى واجهة الملفات", + "body": "عرض أقسامها الخاصة في معاينة الملف، وعناصر في قائمة خيارات الملف." + }, + "ui_commands": { + "title": "إضافة أوامر", + "body": "إضافة عناصرها الخاصة إلى لوحة الأوامر (Ctrl+K)." + }, + "ui_app_page": { + "title": "إضافة صفحات", + "body": "إضافة صفحاتها الخاصة إلى التطبيق، لكل منها زر في شريط التنقل." + }, + "http_proxy": { + "title": "استخدام الحسابات التي تربطها", + "body": "إرسال طلبات عبر خادم Bulwark باستخدام حساب أو مفتاح أعددته أنت أو مسؤول النظام. لا ترى الإضافة كلمة المرور أبدًا، لكنها تستطيع فعل أي شيء يستطيعه ذلك الحساب على خادمه." } }, "dialog": { diff --git a/locales/ca/common.json b/locales/ca/common.json index ba6d47623..04062c552 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4178,8 +4178,8 @@ "body": "Saber quina pàgina està oberta i reaccionar als enllaços en què feu clic." }, "ui_toolbar": { - "title": "Afegir botons a la barra d'eines del missatge", - "body": "Mostrar botons propis a sobre d'un missatge obert." + "title": "Afegir botons a les barres d'eines", + "body": "Mostrar botons propis a la barra d'eines dels missatges, en un missatge obert i a la barra superior." }, "ui_app_top_banner": { "title": "Mostrar un bàner a dalt", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Mostrar avisos als missatges", - "body": "Mostrar un avís a sobre del text d'un missatge obert." + "body": "Mostrar avisos i resums propis a sobre, dins i entre les parts d'un missatge o una conversa oberts." }, "ui_email_footer": { "title": "Afegir contingut sota els missatges", @@ -4210,16 +4210,16 @@ "body": "Classificar la safata d'entrada en pestanyes com ara Actualitzacions o Promocions." }, "ui_composer_toolbar": { - "title": "Afegir botons al redactor", - "body": "Mostrar botons propis mentre escriviu un missatge." + "title": "Afegir elements al redactor", + "body": "Mostrar botons, elements de menú i seccions propis mentre escriviu un missatge." }, "ui_composer_sidebar": { "title": "Afegir un panell al redactor", "body": "Mostrar un panell propi al costat del missatge que escriviu." }, "ui_sidebar_widget": { - "title": "Afegir un quadre a la barra lateral", - "body": "Mostrar contingut propi a la barra lateral del correu." + "title": "Afegir elements a la barra lateral", + "body": "Mostrar quadres i seccions propis a la barra lateral del correu." }, "ui_settings_section": { "title": "Afegir una pàgina de configuració", @@ -4238,8 +4238,8 @@ "body": "Reaccionar a les dreceres de teclat que defineix." }, "ui_calendar_action": { - "title": "Afegir botons als esdeveniments", - "body": "Mostrar botons propis quan creeu o editeu un esdeveniment." + "title": "Afegir elements a la vista del calendari", + "body": "Mostrar botons i seccions propis als esdeveniments, i etiquetes curtes i punts als dies del calendari." }, "ui_admin_page": { "title": "Afegir una pàgina d'administració", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Mostrar notificacions", "body": "Mostrar notificacions al dispositiu, o dins l'aplicació si no les heu permeses. Cadascuna indica el nom del connector." + }, + "ui_message_list": { + "title": "Afegir elements a la llista de missatges", + "body": "Mostrar contingut propi a sobre de la llista de missatges, i botons als missatges i a la barra dels missatges seleccionats." + }, + "ui_contacts": { + "title": "Afegir elements a la vista de contactes", + "body": "Mostrar seccions pròpies als detalls d'un contacte i botons a la llista de contactes." + }, + "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." + }, + "ui_files": { + "title": "Afegir elements a la vista de fitxers", + "body": "Mostrar seccions pròpies a la previsualització de fitxers i entrades al menú d'un fitxer." + }, + "ui_commands": { + "title": "Afegir ordres", + "body": "Afegir entrades pròpies a la paleta d'ordres (Ctrl+K)." + }, + "ui_app_page": { + "title": "Afegir pàgines", + "body": "Afegir pàgines pròpies a l'aplicació, cadascuna amb un botó a la barra de navegació." + }, + "http_proxy": { + "title": "Fer servir els comptes que connecteu", + "body": "Enviar peticions a través del servidor de Bulwark amb un compte o una clau que heu configurat vós o el vostre administrador. El connector no veu mai la contrasenya, però pot fer tot el que aquest compte pot fer al seu propi servidor." } }, "dialog": { diff --git a/locales/cs/common.json b/locales/cs/common.json index bc0152ab3..2c70e6e2c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4178,8 +4178,8 @@ "body": "Ví, která stránka je otevřená, a reaguje na odkazy, na které kliknete." }, "ui_toolbar": { - "title": "Přidávat tlačítka na panel nástrojů zprávy", - "body": "Zobrazuje vlastní tlačítka nad otevřenou zprávou." + "title": "Přidávat tlačítka na panely nástrojů", + "body": "Zobrazuje vlastní tlačítka na panelu nástrojů zpráv, u otevřené zprávy a v horní liště." }, "ui_app_top_banner": { "title": "Zobrazovat banner nahoře", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Zobrazovat oznámení u zpráv", - "body": "Zobrazuje oznámení nad textem otevřené zprávy." + "body": "Zobrazuje vlastní oznámení a shrnutí nad částmi otevřené zprávy nebo konverzace, uvnitř nich a mezi nimi." }, "ui_email_footer": { "title": "Přidávat obsah pod zprávy", @@ -4210,16 +4210,16 @@ "body": "Rozděluje Doručené do karet, například Aktualizace nebo Akce." }, "ui_composer_toolbar": { - "title": "Přidávat tlačítka do editoru zprávy", - "body": "Zobrazuje vlastní tlačítka, když píšete zprávu." + "title": "Přidávat obsah do editoru zprávy", + "body": "Zobrazuje vlastní tlačítka, položky nabídky a sekce, když píšete zprávu." }, "ui_composer_sidebar": { "title": "Přidávat panel do editoru zprávy", "body": "Zobrazuje vlastní panel vedle zprávy, kterou píšete." }, "ui_sidebar_widget": { - "title": "Přidávat pole do postranního panelu", - "body": "Zobrazuje vlastní obsah v postranním panelu pošty." + "title": "Přidávat obsah do postranního panelu", + "body": "Zobrazuje vlastní pole a sekce v postranním panelu pošty." }, "ui_settings_section": { "title": "Přidávat stránku nastavení", @@ -4238,8 +4238,8 @@ "body": "Reaguje na klávesové zkratky, které sám definuje." }, "ui_calendar_action": { - "title": "Přidávat tlačítka k událostem", - "body": "Zobrazuje vlastní tlačítka, když vytváříte nebo upravujete událost." + "title": "Přidávat obsah do sekce Kalendář", + "body": "Zobrazuje vlastní tlačítka a sekce u událostí a krátké popisky a tečky u dnů v kalendáři." }, "ui_admin_page": { "title": "Přidávat stránku administrace", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Zobrazovat oznámení", "body": "Zobrazuje oznámení ve vašem zařízení, nebo v aplikaci, pokud jste je nepovolili. Každé uvádí název pluginu." + }, + "ui_message_list": { + "title": "Přidávat obsah do seznamu zpráv", + "body": "Zobrazuje vlastní obsah nad seznamem zpráv a tlačítka u zpráv a na panelu nástrojů pro vybrané zprávy." + }, + "ui_contacts": { + "title": "Přidávat obsah do sekce Kontakty", + "body": "Zobrazuje vlastní sekce v podrobnostech kontaktu a tlačítka v seznamu kontaktů." + }, + "ui_tasks": { + "title": "Přidávat obsah do sekce Úkoly", + "body": "Zobrazuje vlastní sekce v podrobnostech úkolu a tlačítka v seznamu úkolů." + }, + "ui_files": { + "title": "Přidávat obsah do sekce Soubory", + "body": "Zobrazuje vlastní sekce v náhledu souboru a položky v nabídce souboru." + }, + "ui_commands": { + "title": "Přidávat příkazy", + "body": "Přidává vlastní položky do palety příkazů (Ctrl+K)." + }, + "ui_app_page": { + "title": "Přidávat stránky", + "body": "Přidává do aplikace vlastní stránky, každou s tlačítkem v navigačním panelu." + }, + "http_proxy": { + "title": "Používat účty, které připojíte", + "body": "Odesílá požadavky přes server Bulwarku pomocí účtu nebo klíče, který jste nastavili vy nebo váš správce. Plugin nikdy nevidí heslo, ale může na příslušném serveru dělat cokoli, co může tento účet." } }, "dialog": { diff --git a/locales/da/common.json b/locales/da/common.json index 962a25c74..84ffb09d1 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4178,8 +4178,8 @@ "body": "Vide, hvilken side der er åben, og reagere på links, du klikker på." }, "ui_toolbar": { - "title": "Tilføje knapper til beskedens værktøjslinje", - "body": "Vise sine egne knapper over en åben besked." + "title": "Tilføje knapper til værktøjslinjer", + "body": "Vise sine egne knapper på beskedernes værktøjslinje, på en åben besked og på den øverste bjælke." }, "ui_app_top_banner": { "title": "Vise et banner øverst", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Vise meddelelser på beskeder", - "body": "Vise en meddelelse over teksten i en åben besked." + "body": "Vise sine egne meddelelser og resuméer over, i og mellem delene af en åben besked eller samtale." }, "ui_email_footer": { "title": "Tilføje indhold under beskeder", @@ -4210,16 +4210,16 @@ "body": "Sortere din indbakke i faner som Opdateringer eller Kampagner." }, "ui_composer_toolbar": { - "title": "Tilføje knapper til komponisten", - "body": "Vise sine egne knapper, mens du skriver en besked." + "title": "Tilføje til komponisten", + "body": "Vise sine egne knapper, menupunkter og sektioner, mens du skriver en besked." }, "ui_composer_sidebar": { "title": "Tilføje et panel til komponisten", "body": "Vise sit eget panel ved siden af den besked, du skriver." }, "ui_sidebar_widget": { - "title": "Tilføje en boks til sidepanelet", - "body": "Vise sit eget indhold i mailens sidepanel." + "title": "Tilføje til sidepanelet", + "body": "Vise sine egne bokse og sektioner i mailens sidepanel." }, "ui_settings_section": { "title": "Tilføje en indstillingsside", @@ -4238,8 +4238,8 @@ "body": "Reagere på tastaturgenveje, som det selv definerer." }, "ui_calendar_action": { - "title": "Tilføje knapper til begivenheder", - "body": "Vise sine egne knapper, når du opretter eller redigerer en begivenhed." + "title": "Tilføje til kalendervisningen", + "body": "Vise sine egne knapper og sektioner på begivenheder og korte etiketter og prikker på kalenderdage." }, "ui_admin_page": { "title": "Tilføje en administratorside", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Vise notifikationer", "body": "Vise notifikationer på din enhed eller i appen, hvis du ikke har tilladt dem. Hver af dem nævner pluginets navn." + }, + "ui_message_list": { + "title": "Tilføje til beskedlisten", + "body": "Vise sit eget indhold over beskedlisten og knapper på beskeder og på værktøjslinjen for valgte beskeder." + }, + "ui_contacts": { + "title": "Tilføje til kontaktvisningen", + "body": "Vise sine egne sektioner i en kontakts detaljer og knapper i kontaktlisten." + }, + "ui_tasks": { + "title": "Tilføje til opgavevisningen", + "body": "Vise sine egne sektioner i en opgaves detaljer og knapper i opgavelisten." + }, + "ui_files": { + "title": "Tilføje til filvisningen", + "body": "Vise sine egne sektioner i forhåndsvisningen af filer og punkter i en fils menu." + }, + "ui_commands": { + "title": "Tilføje kommandoer", + "body": "Tilføje sine egne punkter til kommandopaletten (Ctrl+K)." + }, + "ui_app_page": { + "title": "Tilføje sider", + "body": "Tilføje sine egne sider til appen, hver med en knap på navigationslinjen." + }, + "http_proxy": { + "title": "Bruge konti, du forbinder", + "body": "Sende forespørgsler via Bulwarks server med en konto eller nøgle, som du eller din administrator har sat op. Pluginnet ser aldrig adgangskoden, men det kan gøre alt, hvad kontoen kan gøre på sin egen server." } }, "dialog": { diff --git a/locales/de/common.json b/locales/de/common.json index c0d59edb4..d89bc781e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4178,8 +4178,8 @@ "body": "Wissen, welche Seite geöffnet ist, und auf angeklickte Links reagieren." }, "ui_toolbar": { - "title": "Schaltflächen zur Nachrichten-Symbolleiste hinzufügen", - "body": "Eigene Schaltflächen über einer geöffneten Nachricht anzeigen." + "title": "Schaltflächen zu Symbolleisten hinzufügen", + "body": "Eigene Schaltflächen in der Nachrichten-Symbolleiste, bei einer geöffneten Nachricht und in der oberen Leiste anzeigen." }, "ui_app_top_banner": { "title": "Oben ein Banner anzeigen", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Hinweise bei Nachrichten anzeigen", - "body": "Einen Hinweis über dem Text einer geöffneten Nachricht anzeigen." + "body": "Eigene Hinweise und Zusammenfassungen über, in und zwischen den Teilen einer geöffneten Nachricht oder Unterhaltung anzeigen." }, "ui_email_footer": { "title": "Inhalte unter Nachrichten hinzufügen", @@ -4210,16 +4210,16 @@ "body": "Ihren Posteingang in Tabs wie Updates oder Werbung aufteilen." }, "ui_composer_toolbar": { - "title": "Schaltflächen zum Editor hinzufügen", - "body": "Eigene Schaltflächen anzeigen, während Sie eine Nachricht schreiben." + "title": "Zum Editor hinzufügen", + "body": "Eigene Schaltflächen, Menüeinträge und Abschnitte anzeigen, während Sie eine Nachricht schreiben." }, "ui_composer_sidebar": { "title": "Einen Bereich zum Editor hinzufügen", "body": "Einen eigenen Bereich neben der Nachricht anzeigen, die Sie schreiben." }, "ui_sidebar_widget": { - "title": "Ein Feld zur Seitenleiste hinzufügen", - "body": "Eigene Inhalte in der E-Mail-Seitenleiste anzeigen." + "title": "Zur Seitenleiste hinzufügen", + "body": "Eigene Felder und Abschnitte in der E-Mail-Seitenleiste anzeigen." }, "ui_settings_section": { "title": "Eine Einstellungsseite hinzufügen", @@ -4238,8 +4238,8 @@ "body": "Auf selbst festgelegte Tastaturkürzel reagieren." }, "ui_calendar_action": { - "title": "Schaltflächen zu Terminen hinzufügen", - "body": "Eigene Schaltflächen anzeigen, wenn Sie einen Termin erstellen oder bearbeiten." + "title": "Kalenderansicht erweitern", + "body": "Eigene Schaltflächen und Abschnitte bei Terminen sowie kurze Beschriftungen und Punkte an Kalendertagen anzeigen." }, "ui_admin_page": { "title": "Eine Admin-Seite hinzufügen", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Benachrichtigungen anzeigen", "body": "Benachrichtigungen auf Ihrem Gerät anzeigen oder in der App, wenn Sie sie nicht erlaubt haben. Jede nennt den Namen des Plugins." + }, + "ui_message_list": { + "title": "Zur Nachrichtenliste hinzufügen", + "body": "Eigene Inhalte über der Nachrichtenliste sowie Schaltflächen bei Nachrichten und in der Leiste für ausgewählte Nachrichten anzeigen." + }, + "ui_contacts": { + "title": "Kontaktansicht erweitern", + "body": "Eigene Abschnitte in den Details eines Kontakts und Schaltflächen in der Kontaktliste anzeigen." + }, + "ui_tasks": { + "title": "Aufgabenansicht erweitern", + "body": "Eigene Abschnitte in den Details einer Aufgabe und Schaltflächen in der Aufgabenliste anzeigen." + }, + "ui_files": { + "title": "Dateiansicht erweitern", + "body": "Eigene Abschnitte in der Dateivorschau und Einträge im Menü einer Datei anzeigen." + }, + "ui_commands": { + "title": "Befehle hinzufügen", + "body": "Eigene Einträge zur Befehlspalette (Ctrl+K) hinzufügen." + }, + "ui_app_page": { + "title": "Seiten hinzufügen", + "body": "Eigene Seiten zur App hinzufügen, jede mit einer Schaltfläche in der Navigationsleiste." + }, + "http_proxy": { + "title": "Von Ihnen verbundene Konten nutzen", + "body": "Anfragen über den Server von Bulwark senden, mit einem Konto oder Schlüssel, das Sie oder Ihr Administrator eingerichtet haben. Das Plugin sieht das Passwort nie, kann auf dem zugehörigen Server aber alles tun, was dieses Konto darf." } }, "dialog": { diff --git a/locales/es/common.json b/locales/es/common.json index afb40b360..381b16765 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4178,8 +4178,8 @@ "body": "Saber qué página está abierta y reaccionar a los enlaces en los que haces clic." }, "ui_toolbar": { - "title": "Añadir botones a la barra de herramientas del mensaje", - "body": "Mostrar sus propios botones encima de un mensaje abierto." + "title": "Añadir botones a las barras de herramientas", + "body": "Mostrar sus propios botones en la barra de herramientas de mensajes, en un mensaje abierto y en la barra superior." }, "ui_app_top_banner": { "title": "Mostrar un banner arriba", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Mostrar avisos en los mensajes", - "body": "Mostrar un aviso encima del texto de un mensaje abierto." + "body": "Mostrar sus propios avisos y resúmenes encima, dentro y entre las partes de un mensaje o una conversación abiertos." }, "ui_email_footer": { "title": "Añadir contenido debajo de los mensajes", @@ -4210,16 +4210,16 @@ "body": "Ordenar tu bandeja de entrada en pestañas como Novedades o Promociones." }, "ui_composer_toolbar": { - "title": "Añadir botones al editor", - "body": "Mostrar sus propios botones mientras escribes un mensaje." + "title": "Añadir al editor", + "body": "Mostrar sus propios botones, entradas de menú y secciones mientras escribes un mensaje." }, "ui_composer_sidebar": { "title": "Añadir un panel al editor", "body": "Mostrar su propio panel junto al mensaje que escribes." }, "ui_sidebar_widget": { - "title": "Añadir un recuadro a la barra lateral", - "body": "Mostrar su propio contenido en la barra lateral del correo." + "title": "Añadir a la barra lateral", + "body": "Mostrar sus propios recuadros y secciones en la barra lateral del correo." }, "ui_settings_section": { "title": "Añadir una página de configuración", @@ -4238,8 +4238,8 @@ "body": "Reaccionar a los atajos de teclado que define." }, "ui_calendar_action": { - "title": "Añadir botones a los eventos", - "body": "Mostrar sus propios botones cuando creas o editas un evento." + "title": "Añadir a la vista de calendario", + "body": "Mostrar sus propios botones y secciones en los eventos, y etiquetas cortas y puntos en los días del calendario." }, "ui_admin_page": { "title": "Añadir una página de administración", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Mostrar notificaciones", "body": "Mostrar notificaciones en tu dispositivo, o dentro de la aplicación si no las has permitido. Cada una indica el nombre del complemento." + }, + "ui_message_list": { + "title": "Añadir a la lista de mensajes", + "body": "Mostrar su propio contenido encima de la lista de mensajes, y botones en los mensajes y en la barra de los mensajes seleccionados." + }, + "ui_contacts": { + "title": "Añadir a la vista de contactos", + "body": "Mostrar sus propias secciones en los detalles de un contacto y botones en la lista de contactos." + }, + "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." + }, + "ui_files": { + "title": "Añadir a la vista de archivos", + "body": "Mostrar sus propias secciones en la vista previa de archivos y entradas en el menú de un archivo." + }, + "ui_commands": { + "title": "Añadir comandos", + "body": "Añadir sus propias entradas a la paleta de comandos (Ctrl+K)." + }, + "ui_app_page": { + "title": "Añadir páginas", + "body": "Añadir sus propias páginas a la aplicación, cada una con un botón en la barra de navegación." + }, + "http_proxy": { + "title": "Usar las cuentas que conectes", + "body": "Enviar solicitudes a través del servidor de Bulwark con una cuenta o clave configurada por ti o por tu administrador. El complemento nunca ve la contraseña, pero puede hacer todo lo que esa cuenta puede hacer en su propio servidor." } }, "dialog": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 94f11c9ac..abe439825 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4178,8 +4178,8 @@ "body": "دانستن اینکه کدام صفحه باز است و واکنش به پیوندهایی که روی آن‌ها کلیک می‌کنید." }, "ui_toolbar": { - "title": "افزودن دکمه به نوار ابزار پیام", - "body": "نمایش دکمه‌های خودش بالای پیام باز." + "title": "افزودن دکمه به نوارهای ابزار", + "body": "نمایش دکمه‌های خودش در نوار ابزار پیام‌ها، روی پیام باز و در نوار بالا." }, "ui_app_top_banner": { "title": "نمایش بنر در بالا", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "نمایش اطلاعیه روی پیام‌ها", - "body": "نمایش یک اطلاعیه بالای متن پیام باز." + "body": "نمایش اطلاعیه‌ها و خلاصه‌های خودش بالا، داخل و بین بخش‌های پیام یا مکالمه باز." }, "ui_email_footer": { "title": "افزودن محتوا زیر پیام‌ها", @@ -4210,16 +4210,16 @@ "body": "مرتب کردن صندوق ورودی شما در برگه‌هایی مانند به‌روزرسانی‌ها یا تبلیغات." }, "ui_composer_toolbar": { - "title": "افزودن دکمه به ویرایشگر", - "body": "نمایش دکمه‌های خودش هنگام نوشتن پیام." + "title": "افزودن به ویرایشگر", + "body": "نمایش دکمه‌ها، گزینه‌های منو و بخش‌های خودش هنگام نوشتن پیام." }, "ui_composer_sidebar": { "title": "افزودن پنل به ویرایشگر", "body": "نمایش پنل خودش کنار پیامی که می‌نویسید." }, "ui_sidebar_widget": { - "title": "افزودن کادر به نوار کناری", - "body": "نمایش محتوای خودش در نوار کناری ایمیل." + "title": "افزودن به نوار کناری", + "body": "نمایش کادرها و بخش‌های خودش در نوار کناری ایمیل." }, "ui_settings_section": { "title": "افزودن صفحه تنظیمات", @@ -4238,8 +4238,8 @@ "body": "واکنش به میانبرهای صفحه کلیدی که خودش تعریف می‌کند." }, "ui_calendar_action": { - "title": "افزودن دکمه به رویدادها", - "body": "نمایش دکمه‌های خودش هنگام ایجاد یا ویرایش رویداد." + "title": "افزودن به نمای تقویم", + "body": "نمایش دکمه‌ها و بخش‌های خودش روی رویدادها، و برچسب‌های کوتاه و نقطه‌ها روی روزهای تقویم." }, "ui_admin_page": { "title": "افزودن صفحه مدیریت", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "نمایش اعلان‌ها", "body": "نمایش اعلان روی دستگاه شما، یا داخل برنامه اگر اجازه اعلان نداده‌اید. هر اعلان نام افزونه را نشان می‌دهد." + }, + "ui_message_list": { + "title": "افزودن به فهرست پیام‌ها", + "body": "نمایش محتوای خودش بالای فهرست پیام‌ها، و دکمه‌ها روی پیام‌ها و در نوار ابزار پیام‌های انتخاب‌شده." + }, + "ui_contacts": { + "title": "افزودن به نمای مخاطبین", + "body": "نمایش بخش‌های خودش در جزئیات مخاطب و دکمه‌ها در فهرست مخاطبین." + }, + "ui_tasks": { + "title": "افزودن به نمای وظایف", + "body": "نمایش بخش‌های خودش در جزئیات وظیفه و دکمه‌ها در فهرست وظایف." + }, + "ui_files": { + "title": "افزودن به نمای فایل‌ها", + "body": "نمایش بخش‌های خودش در پیش‌نمایش فایل و گزینه‌ها در منوی فایل." + }, + "ui_commands": { + "title": "افزودن فرمان", + "body": "افزودن گزینه‌های خودش به پالت فرمان (Ctrl+K)." + }, + "ui_app_page": { + "title": "افزودن صفحه", + "body": "افزودن صفحه‌های خودش به برنامه، هر کدام با یک دکمه در نوار ناوبری." + }, + "http_proxy": { + "title": "استفاده از حساب‌هایی که متصل می‌کنید", + "body": "ارسال درخواست از طریق سرور Bulwark با حساب یا کلیدی که شما یا مدیرتان راه‌اندازی کرده‌اید. افزونه هرگز گذرواژه را نمی‌بیند، اما می‌تواند هر کاری را که آن حساب روی سرور خودش می‌تواند انجام دهد، انجام دهد." } }, "dialog": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 07bac03ee..5212cee79 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4178,8 +4178,8 @@ "body": "Savoir quelle page est ouverte et réagir aux liens sur lesquels vous cliquez." }, "ui_toolbar": { - "title": "Ajouter des boutons à la barre d'outils des messages", - "body": "Afficher ses propres boutons au-dessus d'un message ouvert." + "title": "Ajouter des boutons aux barres d'outils", + "body": "Afficher ses propres boutons dans la barre d'outils des messages, sur un message ouvert et dans la barre supérieure." }, "ui_app_top_banner": { "title": "Afficher une bannière en haut", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Afficher des avis sur les messages", - "body": "Afficher un avis au-dessus du texte d'un message ouvert." + "body": "Afficher ses propres avis et résumés au-dessus, à l'intérieur et entre les parties d'un message ou d'une conversation ouverts." }, "ui_email_footer": { "title": "Ajouter du contenu sous les messages", @@ -4210,16 +4210,16 @@ "body": "Répartir votre boîte de réception en onglets comme Mises à jour ou Promotions." }, "ui_composer_toolbar": { - "title": "Ajouter des boutons à l'éditeur", - "body": "Afficher ses propres boutons pendant que vous rédigez un message." + "title": "Ajouter à l'éditeur", + "body": "Afficher ses propres boutons, entrées de menu et sections pendant que vous rédigez un message." }, "ui_composer_sidebar": { "title": "Ajouter un panneau à l'éditeur", "body": "Afficher son propre panneau à côté du message que vous rédigez." }, "ui_sidebar_widget": { - "title": "Ajouter un encadré à la barre latérale", - "body": "Afficher son propre contenu dans la barre latérale de la messagerie." + "title": "Ajouter à la barre latérale", + "body": "Afficher ses propres encadrés et sections dans la barre latérale de la messagerie." }, "ui_settings_section": { "title": "Ajouter une page de paramètres", @@ -4238,8 +4238,8 @@ "body": "Réagir aux raccourcis clavier qu'il définit." }, "ui_calendar_action": { - "title": "Ajouter des boutons aux événements", - "body": "Afficher ses propres boutons lorsque vous créez ou modifiez un événement." + "title": "Ajouter à l'affichage du calendrier", + "body": "Afficher ses propres boutons et sections sur les événements, ainsi que de courtes étiquettes et des points sur les jours du calendrier." }, "ui_admin_page": { "title": "Ajouter une page d'administration", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Afficher des notifications", "body": "Afficher des notifications sur votre appareil, ou dans l'application si vous ne les avez pas autorisées. Chacune indique le nom du plugin." + }, + "ui_message_list": { + "title": "Ajouter à la liste des messages", + "body": "Afficher son propre contenu au-dessus de la liste des messages, ainsi que des boutons sur les messages et dans la barre des messages sélectionnés." + }, + "ui_contacts": { + "title": "Ajouter à l'affichage des contacts", + "body": "Afficher ses propres sections dans les détails d'un contact et des boutons dans la liste des contacts." + }, + "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." + }, + "ui_files": { + "title": "Ajouter à l'affichage des fichiers", + "body": "Afficher ses propres sections dans l'aperçu des fichiers et des entrées dans le menu d'un fichier." + }, + "ui_commands": { + "title": "Ajouter des commandes", + "body": "Ajouter ses propres entrées à la palette de commandes (Ctrl+K)." + }, + "ui_app_page": { + "title": "Ajouter des pages", + "body": "Ajouter ses propres pages à l'application, chacune avec un bouton dans la barre de navigation." + }, + "http_proxy": { + "title": "Utiliser les comptes que vous connectez", + "body": "Envoyer des requêtes via le serveur de Bulwark avec un compte ou une clé configurés par vous ou votre administrateur. Le plugin ne voit jamais le mot de passe, mais il peut faire tout ce que ce compte peut faire sur son propre serveur." } }, "dialog": { diff --git a/locales/he/common.json b/locales/he/common.json index 71d8382ce..20b4537eb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4179,8 +4179,8 @@ "body": "לדעת איזה עמוד פתוח ולהגיב לקישורים שאתם לוחצים עליהם." }, "ui_toolbar": { - "title": "הוספת כפתורים לסרגל הכלים של ההודעה", - "body": "הצגת כפתורים משלו מעל הודעה פתוחה." + "title": "הוספת כפתורים לסרגלי כלים", + "body": "הצגת כפתורים משלו בסרגל הכלים של ההודעות, על הודעה פתוחה ובסרגל העליון." }, "ui_app_top_banner": { "title": "הצגת באנר בחלק העליון", @@ -4188,7 +4188,7 @@ }, "ui_email_banner": { "title": "הצגת הודעות על גבי הודעות דואר", - "body": "הצגת הודעה מעל הטקסט של הודעה פתוחה." + "body": "הצגת הודעות וסיכומים משלו מעל החלקים של הודעה או שיחה פתוחה, בתוכם וביניהם." }, "ui_email_footer": { "title": "הוספת תוכן מתחת להודעות", @@ -4211,16 +4211,16 @@ "body": "מיון הדואר הנכנס שלכם ללשוניות כמו עדכונים או קידומי מכירות." }, "ui_composer_toolbar": { - "title": "הוספת כפתורים לעורך", - "body": "הצגת כפתורים משלו בזמן שאתם כותבים הודעה." + "title": "הוספה לעורך", + "body": "הצגת כפתורים, פריטי תפריט ומקטעים משלו בזמן שאתם כותבים הודעה." }, "ui_composer_sidebar": { "title": "הוספת חלונית לעורך", "body": "הצגת חלונית משלו לצד ההודעה שאתם כותבים." }, "ui_sidebar_widget": { - "title": "הוספת תיבה לסרגל הצד", - "body": "הצגת תוכן משלו בסרגל הצד של הדואר." + "title": "הוספה לסרגל הצד", + "body": "הצגת תיבות ומקטעים משלו בסרגל הצד של הדואר." }, "ui_settings_section": { "title": "הוספת עמוד הגדרות", @@ -4239,8 +4239,8 @@ "body": "תגובה לקיצורי מקשים שהוא מגדיר." }, "ui_calendar_action": { - "title": "הוספת כפתורים לאירועים", - "body": "הצגת כפתורים משלו כשאתם יוצרים או עורכים אירוע." + "title": "הוספה לתצוגת לוח השנה", + "body": "הצגת כפתורים ומקטעים משלו על אירועים, ותוויות קצרות ונקודות על ימים בלוח השנה." }, "ui_admin_page": { "title": "הוספת עמוד ניהול", @@ -4257,6 +4257,34 @@ "ui_notifications": { "title": "הצגת התראות", "body": "הצגת התראות במכשיר שלכם, או בתוך האפליקציה אם לא אישרתם התראות. כל התראה מציינת את שם התוסף." + }, + "ui_message_list": { + "title": "הוספה לרשימת ההודעות", + "body": "הצגת תוכן משלו מעל רשימת ההודעות, וכפתורים על הודעות ובסרגל הכלים של ההודעות שנבחרו." + }, + "ui_contacts": { + "title": "הוספה לתצוגת אנשי הקשר", + "body": "הצגת מקטעים משלו בפרטים של איש קשר וכפתורים ברשימת אנשי הקשר." + }, + "ui_tasks": { + "title": "הוספה לתצוגת המשימות", + "body": "הצגת מקטעים משלו בפרטים של משימה וכפתורים ברשימת המשימות." + }, + "ui_files": { + "title": "הוספה לתצוגת הקבצים", + "body": "הצגת מקטעים משלו בתצוגה המקדימה של קבצים ופריטים בתפריט של קובץ." + }, + "ui_commands": { + "title": "הוספת פקודות", + "body": "הוספת פריטים משלו ללוח הפקודות (Ctrl+K)." + }, + "ui_app_page": { + "title": "הוספת עמודים", + "body": "הוספת עמודים משלו לאפליקציה, כל אחד עם כפתור בסרגל הניווט." + }, + "http_proxy": { + "title": "שימוש בחשבונות שאתם מחברים", + "body": "שליחת בקשות דרך השרת של Bulwark עם חשבון או מפתח שהוגדרו על ידיכם או על ידי מנהל המערכת. התוסף אף פעם לא רואה את הסיסמה, אבל הוא יכול לעשות כל מה שהחשבון הזה יכול לעשות בשרת שלו." } }, "dialog": { diff --git a/locales/hu/common.json b/locales/hu/common.json index d3bd32123..a32c5cd47 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4178,8 +4178,8 @@ "body": "Tudja, melyik oldal van megnyitva, és reagál a hivatkozásokra, amelyekre kattintasz." }, "ui_toolbar": { - "title": "Gombok az üzenet eszköztárán", - "body": "Saját gombokat jelenít meg egy megnyitott üzenet felett." + "title": "Gombok az eszköztárakon", + "body": "Saját gombokat jelenít meg az üzenetek eszköztárán, egy megnyitott üzeneten és a felső sávban." }, "ui_app_top_banner": { "title": "Szalag megjelenítése felül", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Értesítések az üzeneteken", - "body": "Értesítést jelenít meg egy megnyitott üzenet szövege felett." + "body": "Saját értesítéseket és összefoglalókat jelenít meg egy megnyitott üzenet vagy beszélgetés részei felett, azokon belül és között." }, "ui_email_footer": { "title": "Tartalom az üzenetek alatt", @@ -4210,16 +4210,16 @@ "body": "Lapokra bontja a Beérkező leveleket, például Frissítések vagy Promóciók." }, "ui_composer_toolbar": { - "title": "Gombok a szerkesztőben", - "body": "Saját gombokat jelenít meg, miközben üzenetet írsz." + "title": "Tartalom a szerkesztőben", + "body": "Saját gombokat, menüpontokat és szakaszokat jelenít meg, miközben üzenetet írsz." }, "ui_composer_sidebar": { "title": "Panel a szerkesztőben", "body": "Saját panelt jelenít meg az általad írt üzenet mellett." }, "ui_sidebar_widget": { - "title": "Doboz az oldalsávon", - "body": "Saját tartalmat jelenít meg a levelezés oldalsávján." + "title": "Tartalom az oldalsávon", + "body": "Saját dobozokat és szakaszokat jelenít meg a levelezés oldalsávján." }, "ui_settings_section": { "title": "Beállítási oldal hozzáadása", @@ -4238,8 +4238,8 @@ "body": "Reagál az általa meghatározott billentyűparancsokra." }, "ui_calendar_action": { - "title": "Gombok az eseményeknél", - "body": "Saját gombokat jelenít meg, amikor eseményt hozol létre vagy szerkesztesz." + "title": "Tartalom a naptárnézetben", + "body": "Saját gombokat és szakaszokat jelenít meg az eseményeken, valamint rövid címkéket és pontokat a naptár napjain." }, "ui_admin_page": { "title": "Adminisztrációs oldal hozzáadása", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Értesítések megjelenítése", "body": "Értesítéseket jelenít meg az eszközödön, vagy az alkalmazásban, ha nem engedélyezted őket. Mindegyiken szerepel a bővítmény neve." + }, + "ui_message_list": { + "title": "Tartalom az üzenetlistában", + "body": "Saját tartalmat jelenít meg az üzenetlista felett, valamint gombokat az üzeneteken és a kijelölt üzenetek eszköztárán." + }, + "ui_contacts": { + "title": "Tartalom a névjegyek nézetében", + "body": "Saját szakaszokat jelenít meg egy névjegy részleteiben és gombokat a névjegylistában." + }, + "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." + }, + "ui_files": { + "title": "Tartalom a fájlok nézetében", + "body": "Saját szakaszokat jelenít meg a fájlelőnézetben és menüpontokat egy fájl menüjében." + }, + "ui_commands": { + "title": "Saját parancsok", + "body": "Saját elemeket ad a parancspalettához (Ctrl+K)." + }, + "ui_app_page": { + "title": "Saját oldalak", + "body": "Saját oldalakat ad az alkalmazáshoz, mindegyiket egy gombbal a navigációs sávon." + }, + "http_proxy": { + "title": "Az általad csatlakoztatott fiókok használata", + "body": "Kéréseket küld a Bulwark szerverén keresztül egy olyan fiókkal vagy kulccsal, amelyet te vagy a rendszergazdád állított be. A bővítmény soha nem látja a jelszót, de bármit megtehet, amit az a fiók a saját szerverén megtehet." } }, "dialog": { diff --git a/locales/it/common.json b/locales/it/common.json index e8c2d9488..38fb72610 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4178,8 +4178,8 @@ "body": "Sapere quale pagina è aperta e reagire ai link su cui fai clic." }, "ui_toolbar": { - "title": "Aggiungere pulsanti alla barra degli strumenti del messaggio", - "body": "Mostrare i propri pulsanti sopra un messaggio aperto." + "title": "Aggiungere pulsanti alle barre degli strumenti", + "body": "Mostrare i propri pulsanti nella barra degli strumenti dei messaggi, su un messaggio aperto e nella barra superiore." }, "ui_app_top_banner": { "title": "Mostrare un banner in alto", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Mostrare avvisi sui messaggi", - "body": "Mostrare un avviso sopra il testo di un messaggio aperto." + "body": "Mostrare i propri avvisi e riepiloghi sopra, dentro e tra le parti di un messaggio o di una conversazione aperti." }, "ui_email_footer": { "title": "Aggiungere contenuti sotto i messaggi", @@ -4210,16 +4210,16 @@ "body": "Suddividere la posta in arrivo in schede come Aggiornamenti o Promozioni." }, "ui_composer_toolbar": { - "title": "Aggiungere pulsanti all'editor", - "body": "Mostrare i propri pulsanti mentre scrivi un messaggio." + "title": "Aggiungere elementi all'editor", + "body": "Mostrare i propri pulsanti, voci di menu e sezioni mentre scrivi un messaggio." }, "ui_composer_sidebar": { "title": "Aggiungere un pannello all'editor", "body": "Mostrare un proprio pannello accanto al messaggio che scrivi." }, "ui_sidebar_widget": { - "title": "Aggiungere un riquadro alla barra laterale", - "body": "Mostrare i propri contenuti nella barra laterale della posta." + "title": "Aggiungere elementi alla barra laterale", + "body": "Mostrare i propri riquadri e sezioni nella barra laterale della posta." }, "ui_settings_section": { "title": "Aggiungere una pagina di impostazioni", @@ -4238,8 +4238,8 @@ "body": "Reagire alle scorciatoie da tastiera che definisce." }, "ui_calendar_action": { - "title": "Aggiungere pulsanti agli eventi", - "body": "Mostrare i propri pulsanti quando crei o modifichi un evento." + "title": "Aggiungere elementi alla vista calendario", + "body": "Mostrare i propri pulsanti e sezioni sugli eventi, e brevi etichette e puntini sui giorni del calendario." }, "ui_admin_page": { "title": "Aggiungere una pagina di amministrazione", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Mostrare notifiche", "body": "Mostrare notifiche sul tuo dispositivo, o nell'app se non le hai consentite. Ognuna indica il nome del plugin." + }, + "ui_message_list": { + "title": "Aggiungere elementi all'elenco dei messaggi", + "body": "Mostrare i propri contenuti sopra l'elenco dei messaggi, e pulsanti sui messaggi e nella barra dei messaggi selezionati." + }, + "ui_contacts": { + "title": "Aggiungere elementi alla vista contatti", + "body": "Mostrare le proprie sezioni nei dettagli di un contatto e pulsanti nell'elenco dei contatti." + }, + "ui_tasks": { + "title": "Aggiungere elementi alla vista attività", + "body": "Mostrare le proprie sezioni nei dettagli di un'attività e pulsanti nell'elenco delle attività." + }, + "ui_files": { + "title": "Aggiungere elementi alla vista file", + "body": "Mostrare le proprie sezioni nell'anteprima dei file e voci nel menu di un file." + }, + "ui_commands": { + "title": "Aggiungere comandi", + "body": "Aggiungere le proprie voci alla palette dei comandi (Ctrl+K)." + }, + "ui_app_page": { + "title": "Aggiungere pagine", + "body": "Aggiungere le proprie pagine all'app, ognuna con un pulsante nella barra di navigazione." + }, + "http_proxy": { + "title": "Usare gli account che colleghi", + "body": "Inviare richieste tramite il server di Bulwark con un account o una chiave configurati da te o dal tuo amministratore. Il plugin non vede mai la password, ma può fare tutto ciò che quell'account può fare sul proprio server." } }, "dialog": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 5674780c8..518dc7fff 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4178,8 +4178,8 @@ "body": "どのページが開いているかを知り、クリックしたリンクに反応できます。" }, "ui_toolbar": { - "title": "メッセージのツールバーにボタンを追加", - "body": "開いたメッセージの上に独自のボタンを表示します。" + "title": "ツールバーにボタンを追加", + "body": "メッセージのツールバー、開いたメッセージ、上部のバーに独自のボタンを表示します。" }, "ui_app_top_banner": { "title": "上部にバナーを表示", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "メッセージにお知らせを表示", - "body": "開いたメッセージの本文の上にお知らせを表示します。" + "body": "開いたメッセージや会話の各部分の上、中、間に独自のお知らせや要約を表示します。" }, "ui_email_footer": { "title": "メッセージの下に内容を追加", @@ -4210,16 +4210,16 @@ "body": "受信トレイを「更新」や「プロモーション」などのタブに分けます。" }, "ui_composer_toolbar": { - "title": "作成画面にボタンを追加", - "body": "メッセージの作成中に独自のボタンを表示します。" + "title": "作成画面に追加", + "body": "メッセージの作成中に独自のボタン、メニュー項目、セクションを表示します。" }, "ui_composer_sidebar": { "title": "作成画面にパネルを追加", "body": "作成中のメッセージの横に独自のパネルを表示します。" }, "ui_sidebar_widget": { - "title": "サイドバーにボックスを追加", - "body": "メールのサイドバーに独自の内容を表示します。" + "title": "サイドバーに追加", + "body": "メールのサイドバーに独自のボックスやセクションを表示します。" }, "ui_settings_section": { "title": "設定ページを追加", @@ -4238,8 +4238,8 @@ "body": "独自に定義したキーボードショートカットに反応します。" }, "ui_calendar_action": { - "title": "予定にボタンを追加", - "body": "予定の作成時や編集時に独自のボタンを表示します。" + "title": "カレンダー画面に追加", + "body": "予定に独自のボタンやセクションを表示し、カレンダーの日付に短いラベルや点を表示します。" }, "ui_admin_page": { "title": "管理ページを追加", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "通知を表示", "body": "デバイスに通知を表示します。通知を許可していない場合はアプリ内に表示します。どの通知にもプラグイン名が入ります。" + }, + "ui_message_list": { + "title": "メッセージ一覧に追加", + "body": "メッセージ一覧の上に独自の内容を表示し、各メッセージと選択中のメッセージ用のツールバーにボタンを表示します。" + }, + "ui_contacts": { + "title": "連絡先画面に追加", + "body": "連絡先の詳細に独自のセクションを、連絡先一覧にボタンを表示します。" + }, + "ui_tasks": { + "title": "タスク画面に追加", + "body": "タスクの詳細に独自のセクションを、タスク一覧にボタンを表示します。" + }, + "ui_files": { + "title": "ファイル画面に追加", + "body": "ファイルのプレビューに独自のセクションを、ファイルのメニューに項目を表示します。" + }, + "ui_commands": { + "title": "コマンドを追加", + "body": "コマンドパレット (Ctrl+K) に独自の項目を追加します。" + }, + "ui_app_page": { + "title": "ページを追加", + "body": "アプリに独自のページを追加し、それぞれのボタンをナビゲーションバーに表示します。" + }, + "http_proxy": { + "title": "接続したアカウントを使用", + "body": "あなたまたは管理者が設定したアカウントやキーを使って、Bulwark のサーバー経由でリクエストを送信します。プラグインがパスワードを見ることはありませんが、そのアカウントがそのサーバーでできることはすべて実行できます。" } }, "dialog": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 110eaacb9..9490975e9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4178,8 +4178,8 @@ "body": "어떤 페이지가 열려 있는지 알고, 클릭한 링크에 반응할 수 있어요." }, "ui_toolbar": { - "title": "메시지 도구 모음에 버튼 추가", - "body": "열어 본 메시지 위에 자체 버튼을 표시해요." + "title": "도구 모음에 버튼 추가", + "body": "메시지 도구 모음, 열어 본 메시지, 상단 바에 자체 버튼을 표시해요." }, "ui_app_top_banner": { "title": "상단에 배너 표시", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "메시지에 안내 표시", - "body": "열어 본 메시지 본문 위에 안내를 표시해요." + "body": "열어 본 메시지나 대화의 각 부분 위, 안, 사이에 자체 안내와 요약을 표시해요." }, "ui_email_footer": { "title": "메시지 아래에 내용 추가", @@ -4210,16 +4210,16 @@ "body": "받은편지함을 업데이트나 프로모션 같은 탭으로 나눠요." }, "ui_composer_toolbar": { - "title": "작성 창에 버튼 추가", - "body": "메시지를 작성하는 동안 자체 버튼을 표시해요." + "title": "작성 창에 추가", + "body": "메시지를 작성하는 동안 자체 버튼, 메뉴 항목, 섹션을 표시해요." }, "ui_composer_sidebar": { "title": "작성 창에 패널 추가", "body": "작성 중인 메시지 옆에 자체 패널을 표시해요." }, "ui_sidebar_widget": { - "title": "사이드바에 상자 추가", - "body": "메일 사이드바에 자체 내용을 표시해요." + "title": "사이드바에 추가", + "body": "메일 사이드바에 자체 상자와 섹션을 표시해요." }, "ui_settings_section": { "title": "설정 페이지 추가", @@ -4238,8 +4238,8 @@ "body": "직접 정의한 단축키에 반응해요." }, "ui_calendar_action": { - "title": "일정에 버튼 추가", - "body": "일정을 만들거나 편집할 때 자체 버튼을 표시해요." + "title": "캘린더 화면에 추가", + "body": "일정에 자체 버튼과 섹션을, 캘린더 날짜에 짧은 라벨과 점을 표시해요." }, "ui_admin_page": { "title": "관리자 페이지 추가", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "알림 표시", "body": "기기에 알림을 표시하고, 알림을 허용하지 않았다면 앱 안에 표시해요. 모든 알림에 플러그인 이름이 나와요." + }, + "ui_message_list": { + "title": "메시지 목록에 추가", + "body": "메시지 목록 위에 자체 내용을 표시하고, 메시지와 선택한 메시지의 도구 모음에 버튼을 표시해요." + }, + "ui_contacts": { + "title": "연락처 화면에 추가", + "body": "연락처 세부 정보에 자체 섹션을, 연락처 목록에 버튼을 표시해요." + }, + "ui_tasks": { + "title": "할 일 화면에 추가", + "body": "할 일 세부 정보에 자체 섹션을, 할 일 목록에 버튼을 표시해요." + }, + "ui_files": { + "title": "파일 화면에 추가", + "body": "파일 미리보기에 자체 섹션을, 파일 메뉴에 항목을 표시해요." + }, + "ui_commands": { + "title": "명령 추가", + "body": "명령 팔레트(Ctrl+K)에 자체 항목을 추가해요." + }, + "ui_app_page": { + "title": "페이지 추가", + "body": "앱에 자체 페이지를 추가하고, 각 페이지의 버튼을 내비게이션 바에 표시해요." + }, + "http_proxy": { + "title": "연결한 계정 사용", + "body": "나 또는 관리자가 설정한 계정이나 키로 Bulwark 서버를 거쳐 요청을 보낼 수 있어요. 플러그인은 비밀번호를 볼 수 없지만, 해당 계정이 그 서버에서 할 수 있는 모든 작업을 할 수 있어요." } }, "dialog": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 6f39d9f01..ab8308a8b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4178,8 +4178,8 @@ "body": "Zināt, kura lapa ir atvērta, un reaģēt uz saitēm, uz kurām noklikšķināt." }, "ui_toolbar": { - "title": "Pievienot pogas vēstules rīkjoslai", - "body": "Rādīt savas pogas virs atvērtas vēstules." + "title": "Pievienot pogas rīkjoslām", + "body": "Rādīt savas pogas vēstuļu rīkjoslā, atvērtā vēstulē un augšējā joslā." }, "ui_app_top_banner": { "title": "Rādīt reklāmkarogu augšā", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Rādīt paziņojumus vēstulēs", - "body": "Rādīt paziņojumu virs atvērtas vēstules teksta." + "body": "Rādīt savus paziņojumus un kopsavilkumus virs atvērtas vēstules vai sarunas daļām, to iekšpusē un starp tām." }, "ui_email_footer": { "title": "Pievienot saturu zem vēstulēm", @@ -4210,16 +4210,16 @@ "body": "Sadalīt jūsu iesūtni cilnēs, piemēram, Jaunumi vai Akcijas." }, "ui_composer_toolbar": { - "title": "Pievienot pogas redaktoram", - "body": "Rādīt savas pogas, kamēr rakstāt vēstuli." + "title": "Papildināt redaktoru", + "body": "Rādīt savas pogas, izvēlnes ierakstus un sadaļas, kamēr rakstāt vēstuli." }, "ui_composer_sidebar": { "title": "Pievienot paneli redaktoram", "body": "Rādīt savu paneli blakus vēstulei, ko rakstāt." }, "ui_sidebar_widget": { - "title": "Pievienot bloku sānjoslai", - "body": "Rādīt savu saturu pasta sānjoslā." + "title": "Papildināt sānjoslu", + "body": "Rādīt savus blokus un sadaļas pasta sānjoslā." }, "ui_settings_section": { "title": "Pievienot iestatījumu lapu", @@ -4238,8 +4238,8 @@ "body": "Reaģēt uz paša definētiem īsinājumtaustiņiem." }, "ui_calendar_action": { - "title": "Pievienot pogas notikumiem", - "body": "Rādīt savas pogas, kad izveidojat vai rediģējat notikumu." + "title": "Papildināt kalendāra skatu", + "body": "Rādīt savas pogas un sadaļas pie notikumiem un īsas birkas un punktus kalendāra dienās." }, "ui_admin_page": { "title": "Pievienot administrēšanas lapu", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Rādīt paziņojumus", "body": "Rādīt paziņojumus jūsu ierīcē vai lietotnē, ja neesat tos atļāvis. Katrā ir spraudņa nosaukums." + }, + "ui_message_list": { + "title": "Papildināt vēstuļu sarakstu", + "body": "Rādīt savu saturu virs vēstuļu saraksta un pogas pie vēstulēm un atlasīto vēstuļu rīkjoslā." + }, + "ui_contacts": { + "title": "Papildināt kontaktu skatu", + "body": "Rādīt savas sadaļas kontakta informācijā un pogas kontaktu sarakstā." + }, + "ui_tasks": { + "title": "Papildināt uzdevumu skatu", + "body": "Rādīt savas sadaļas uzdevuma informācijā un pogas uzdevumu sarakstā." + }, + "ui_files": { + "title": "Papildināt failu skatu", + "body": "Rādīt savas sadaļas failu priekšskatījumā un ierakstus faila izvēlnē." + }, + "ui_commands": { + "title": "Pievienot komandas", + "body": "Pievienot savus ierakstus komandu paletei (Ctrl+K)." + }, + "ui_app_page": { + "title": "Pievienot lapas", + "body": "Pievienot lietotnei savas lapas, katru ar pogu navigācijas joslā." + }, + "http_proxy": { + "title": "Izmantot jūsu savienotos kontus", + "body": "Sūtīt pieprasījumus caur Bulwark serveri ar kontu vai atslēgu, ko iestatījāt jūs vai jūsu administrators. Spraudnis nekad neredz paroli, taču tas var darīt visu, ko šis konts var darīt savā serverī." } }, "dialog": { diff --git a/locales/mn/common.json b/locales/mn/common.json index f25e98af5..e4a56f2dd 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4178,8 +4178,8 @@ "body": "Аль хуудас нээлттэй байгааг мэдэх, таны дарсан холбоосод хариу үйлдэл үзүүлэх." }, "ui_toolbar": { - "title": "Имэйлийн хэрэгслийн мөрөнд товч нэмэх", - "body": "Нээлттэй имэйлийн дээр өөрийн товчуудыг харуулах." + "title": "Хэрэгслийн мөрөнд товч нэмэх", + "body": "Имэйлийн хэрэгслийн мөр, нээлттэй имэйл болон дээд мөрөнд өөрийн товчуудыг харуулах." }, "ui_app_top_banner": { "title": "Дээд хэсэгт баннер харуулах", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Имэйл дээр мэдэгдэл харуулах", - "body": "Нээлттэй имэйлийн текстийн дээр мэдэгдэл харуулах." + "body": "Нээлттэй имэйл эсвэл ярилцлагын хэсгүүдийн дээр, дотор болон хооронд өөрийн мэдэгдэл, хураангуйг харуулах." }, "ui_email_footer": { "title": "Имэйлийн доор агуулга нэмэх", @@ -4210,16 +4210,16 @@ "body": "Ирсэн имэйлийг Шинэчлэлт, Сурталчилгаа зэрэг табуудад ангилах." }, "ui_composer_toolbar": { - "title": "Имэйл бичих цонхонд товч нэмэх", - "body": "Таныг имэйл бичих үед өөрийн товчуудыг харуулах." + "title": "Имэйл бичих цонхонд нэмэх", + "body": "Таныг имэйл бичих үед өөрийн товч, цэсийн зүйл, хэсгүүдийг харуулах." }, "ui_composer_sidebar": { "title": "Имэйл бичих цонхонд самбар нэмэх", "body": "Таны бичиж буй имэйлийн хажууд өөрийн самбарыг харуулах." }, "ui_sidebar_widget": { - "title": "Хажуугийн самбарт хэсэг нэмэх", - "body": "Имэйлийн хажуугийн самбарт өөрийн агуулгыг харуулах." + "title": "Хажуугийн самбарт нэмэх", + "body": "Имэйлийн хажуугийн самбарт өөрийн хайрцаг, хэсгүүдийг харуулах." }, "ui_settings_section": { "title": "Тохиргооны хуудас нэмэх", @@ -4238,8 +4238,8 @@ "body": "Өөрийн тодорхойлсон товчлуурын товчлолд хариу үйлдэл үзүүлэх." }, "ui_calendar_action": { - "title": "Үйл явдалд товч нэмэх", - "body": "Таныг үйл явдал үүсгэх эсвэл засах үед өөрийн товчуудыг харуулах." + "title": "Хуанлийн харагдацад нэмэх", + "body": "Үйл явдал дээр өөрийн товч, хэсгүүдийг, хуанлийн өдрүүд дээр богино шошго, цэгүүдийг харуулах." }, "ui_admin_page": { "title": "Админ хуудас нэмэх", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Мэдэгдэл харуулах", "body": "Таны төхөөрөмж дээр мэдэгдэл харуулах, эсвэл мэдэгдлийг зөвшөөрөөгүй бол аппын дотор харуулах. Мэдэгдэл бүр залгаасын нэрийг заана." + }, + "ui_message_list": { + "title": "Имэйлийн жагсаалтад нэмэх", + "body": "Имэйлийн жагсаалтын дээр өөрийн агуулгыг, имэйлүүд дээр болон сонгосон имэйлийн хэрэгслийн мөрөнд товчуудыг харуулах." + }, + "ui_contacts": { + "title": "Харилцагчдын харагдацад нэмэх", + "body": "Харилцагчийн дэлгэрэнгүйд өөрийн хэсгүүдийг, харилцагчдын жагсаалтад товчуудыг харуулах." + }, + "ui_tasks": { + "title": "Даалгаврын харагдацад нэмэх", + "body": "Даалгаврын дэлгэрэнгүйд өөрийн хэсгүүдийг, даалгаврын жагсаалтад товчуудыг харуулах." + }, + "ui_files": { + "title": "Файлын харагдацад нэмэх", + "body": "Файлын урьдчилан харахад өөрийн хэсгүүдийг, файлын цэсэнд зүйлсийг харуулах." + }, + "ui_commands": { + "title": "Тушаал нэмэх", + "body": "Тушаалын самбарт (Ctrl+K) өөрийн зүйлсийг нэмэх." + }, + "ui_app_page": { + "title": "Хуудас нэмэх", + "body": "Аппад өөрийн хуудсуудыг нэмэх, тус бүр навигацын мөрөнд товчтой." + }, + "http_proxy": { + "title": "Таны холбосон бүртгэлүүдийг ашиглах", + "body": "Таны эсвэл администраторын тохируулсан бүртгэл эсвэл түлхүүрээр Bulwark-ийн серверээр дамжуулан хүсэлт илгээх. Залгаас нууц үгийг хэзээ ч харахгүй, гэхдээ тухайн бүртгэлийн өөрийн сервер дээр хийж чадах бүх зүйлийг хийж чадна." } }, "dialog": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 17935df59..9d210b7e5 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4178,8 +4178,8 @@ "body": "Vite hvilken side som er åpen, og reagere på lenker du klikker på." }, "ui_toolbar": { - "title": "Legge til knapper i meldingsverktøylinjen", - "body": "Vise egne knapper over en åpen melding." + "title": "Legge til knapper i verktøylinjer", + "body": "Vise egne knapper i meldingsverktøylinjen, på en åpen melding og i topplinjen." }, "ui_app_top_banner": { "title": "Vise et banner øverst", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Vise varsler på meldinger", - "body": "Vise et varsel over teksten i en åpen melding." + "body": "Vise egne varsler og sammendrag over, i og mellom delene av en åpen melding eller samtale." }, "ui_email_footer": { "title": "Legge til innhold under meldinger", @@ -4210,16 +4210,16 @@ "body": "Sortere innboksen din i faner som Oppdateringer eller Kampanjer." }, "ui_composer_toolbar": { - "title": "Legge til knapper i meldingsvinduet", - "body": "Vise egne knapper mens du skriver en melding." + "title": "Legge til i meldingsvinduet", + "body": "Vise egne knapper, menyvalg og seksjoner mens du skriver en melding." }, "ui_composer_sidebar": { "title": "Legge til et panel i meldingsvinduet", "body": "Vise et eget panel ved siden av meldingen du skriver." }, "ui_sidebar_widget": { - "title": "Legge til en boks i sidefeltet", - "body": "Vise eget innhold i sidefeltet for e-post." + "title": "Legge til i sidefeltet", + "body": "Vise egne bokser og seksjoner i sidefeltet for e-post." }, "ui_settings_section": { "title": "Legge til en innstillingsside", @@ -4238,8 +4238,8 @@ "body": "Reagere på tastatursnarveier det definerer selv." }, "ui_calendar_action": { - "title": "Legge til knapper på hendelser", - "body": "Vise egne knapper når du oppretter eller redigerer en hendelse." + "title": "Legge til i kalendervisningen", + "body": "Vise egne knapper og seksjoner på hendelser, og korte etiketter og prikker på kalenderdager." }, "ui_admin_page": { "title": "Legge til en administrasjonsside", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Vise varsler", "body": "Vise varsler på enheten din, eller i appen hvis du ikke har tillatt dem. Hvert varsel viser navnet på tillegget." + }, + "ui_message_list": { + "title": "Legge til i meldingslisten", + "body": "Vise eget innhold over meldingslisten, og knapper på meldinger og i verktøylinjen for valgte meldinger." + }, + "ui_contacts": { + "title": "Legge til i kontaktvisningen", + "body": "Vise egne seksjoner i detaljene for en kontakt og knapper i kontaktlisten." + }, + "ui_tasks": { + "title": "Legge til i oppgavevisningen", + "body": "Vise egne seksjoner i detaljene for en oppgave og knapper i oppgavelisten." + }, + "ui_files": { + "title": "Legge til i filvisningen", + "body": "Vise egne seksjoner i forhåndsvisningen av filer og valg i menyen for en fil." + }, + "ui_commands": { + "title": "Legge til kommandoer", + "body": "Legge til egne valg i kommandopaletten (Ctrl+K)." + }, + "ui_app_page": { + "title": "Legge til sider", + "body": "Legge til egne sider i appen, hver med en knapp i navigasjonsfeltet." + }, + "http_proxy": { + "title": "Bruke kontoer du kobler til", + "body": "Sende forespørsler via Bulwarks server med en konto eller nøkkel som du eller administratoren din har satt opp. Tillegget ser aldri passordet, men det kan gjøre alt den kontoen kan gjøre på sin egen server." } }, "dialog": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 0aecde788..d48e2535b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4178,8 +4178,8 @@ "body": "Weten welke pagina open is en reageren op links waarop je klikt." }, "ui_toolbar": { - "title": "Knoppen toevoegen aan de berichtwerkbalk", - "body": "Eigen knoppen tonen boven een geopend bericht." + "title": "Knoppen toevoegen aan werkbalken", + "body": "Eigen knoppen tonen in de berichtwerkbalk, bij een geopend bericht en in de bovenbalk." }, "ui_app_top_banner": { "title": "Een banner bovenaan tonen", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Meldingen bij berichten tonen", - "body": "Een melding tonen boven de tekst van een geopend bericht." + "body": "Eigen meldingen en samenvattingen tonen boven, in en tussen de delen van een geopend bericht of gesprek." }, "ui_email_footer": { "title": "Inhoud onder berichten toevoegen", @@ -4210,16 +4210,16 @@ "body": "Je Postvak IN in tabbladen indelen, zoals Updates of Promoties." }, "ui_composer_toolbar": { - "title": "Knoppen toevoegen aan de opsteller", - "body": "Eigen knoppen tonen terwijl je een bericht schrijft." + "title": "Toevoegen aan de opsteller", + "body": "Eigen knoppen, menu-items en secties tonen terwijl je een bericht schrijft." }, "ui_composer_sidebar": { "title": "Een paneel toevoegen aan de opsteller", "body": "Een eigen paneel tonen naast het bericht dat je schrijft." }, "ui_sidebar_widget": { - "title": "Een vak toevoegen aan de zijbalk", - "body": "Eigen inhoud tonen in de zijbalk van de mail." + "title": "Toevoegen aan de zijbalk", + "body": "Eigen vakken en secties tonen in de zijbalk van de mail." }, "ui_settings_section": { "title": "Een instellingenpagina toevoegen", @@ -4238,8 +4238,8 @@ "body": "Reageren op sneltoetsen die hij zelf vastlegt." }, "ui_calendar_action": { - "title": "Knoppen toevoegen aan afspraken", - "body": "Eigen knoppen tonen wanneer je een afspraak aanmaakt of bewerkt." + "title": "Toevoegen aan de agendaweergave", + "body": "Eigen knoppen en secties tonen bij afspraken, en korte labels en stippen bij dagen in de agenda." }, "ui_admin_page": { "title": "Een beheerpagina toevoegen", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Meldingen tonen", "body": "Meldingen op je apparaat tonen, of in de app als je ze niet hebt toegestaan. Elke melding noemt de naam van de plug-in." + }, + "ui_message_list": { + "title": "Toevoegen aan de berichtenlijst", + "body": "Eigen inhoud tonen boven de berichtenlijst, en knoppen bij berichten en in de werkbalk voor geselecteerde berichten." + }, + "ui_contacts": { + "title": "Toevoegen aan de contactenweergave", + "body": "Eigen secties tonen in de details van een contact en knoppen in de contactenlijst." + }, + "ui_tasks": { + "title": "Toevoegen aan de takenweergave", + "body": "Eigen secties tonen in de details van een taak en knoppen in de takenlijst." + }, + "ui_files": { + "title": "Toevoegen aan de bestandenweergave", + "body": "Eigen secties tonen in het bestandsvoorbeeld en items in het menu van een bestand." + }, + "ui_commands": { + "title": "Opdrachten toevoegen", + "body": "Eigen items toevoegen aan het opdrachtenpalet (Ctrl+K)." + }, + "ui_app_page": { + "title": "Pagina's toevoegen", + "body": "Eigen pagina's toevoegen aan de app, elk met een knop in de navigatiebalk." + }, + "http_proxy": { + "title": "Accounts gebruiken die je koppelt", + "body": "Verzoeken via de server van Bulwark sturen met een account of sleutel die jij of je beheerder heeft ingesteld. De plug-in ziet het wachtwoord nooit, maar kan alles doen wat dat account op zijn eigen server mag." } }, "dialog": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 23f4e6579..bb04b7de6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4178,8 +4178,8 @@ "body": "Wie, która strona jest otwarta, i reaguje na klikane linki." }, "ui_toolbar": { - "title": "Przyciski na pasku narzędzi wiadomości", - "body": "Pokazuje własne przyciski nad otwartą wiadomością." + "title": "Przyciski na paskach narzędzi", + "body": "Pokazuje własne przyciski na pasku narzędzi wiadomości, przy otwartej wiadomości i na górnym pasku." }, "ui_app_top_banner": { "title": "Baner u góry", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Komunikaty przy wiadomościach", - "body": "Pokazuje komunikat nad treścią otwartej wiadomości." + "body": "Pokazuje własne komunikaty i podsumowania nad częściami otwartej wiadomości lub konwersacji, w nich i między nimi." }, "ui_email_footer": { "title": "Treść pod wiadomościami", @@ -4210,16 +4210,16 @@ "body": "Dzieli Odebrane na karty, takie jak Aktualizacje czy Oferty." }, "ui_composer_toolbar": { - "title": "Przyciski w edytorze wiadomości", - "body": "Pokazuje własne przyciski podczas pisania wiadomości." + "title": "Elementy w edytorze wiadomości", + "body": "Pokazuje własne przyciski, pozycje menu i sekcje podczas pisania wiadomości." }, "ui_composer_sidebar": { "title": "Panel w edytorze wiadomości", "body": "Pokazuje własny panel obok pisanej wiadomości." }, "ui_sidebar_widget": { - "title": "Pole na pasku bocznym", - "body": "Pokazuje własną treść na pasku bocznym poczty." + "title": "Elementy na pasku bocznym", + "body": "Pokazuje własne pola i sekcje na pasku bocznym poczty." }, "ui_settings_section": { "title": "Strona ustawień", @@ -4238,8 +4238,8 @@ "body": "Reaguje na zdefiniowane przez siebie skróty klawiszowe." }, "ui_calendar_action": { - "title": "Przyciski przy wydarzeniach", - "body": "Pokazuje własne przyciski podczas tworzenia lub edytowania wydarzenia." + "title": "Elementy w widoku kalendarza", + "body": "Pokazuje własne przyciski i sekcje przy wydarzeniach oraz krótkie etykiety i kropki przy dniach w kalendarzu." }, "ui_admin_page": { "title": "Strona administracyjna", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Wyświetlanie powiadomień", "body": "Wyświetla powiadomienia na urządzeniu lub w aplikacji, jeśli ich nie zezwolono. Każde podaje nazwę wtyczki." + }, + "ui_message_list": { + "title": "Elementy na liście wiadomości", + "body": "Pokazuje własną treść nad listą wiadomości oraz przyciski przy wiadomościach i na pasku narzędzi zaznaczonych wiadomości." + }, + "ui_contacts": { + "title": "Elementy w widoku kontaktów", + "body": "Pokazuje własne sekcje w szczegółach kontaktu i przyciski na liście kontaktów." + }, + "ui_tasks": { + "title": "Elementy w widoku zadań", + "body": "Pokazuje własne sekcje w szczegółach zadania i przyciski na liście zadań." + }, + "ui_files": { + "title": "Elementy w widoku plików", + "body": "Pokazuje własne sekcje w podglądzie pliku i pozycje w menu pliku." + }, + "ui_commands": { + "title": "Własne polecenia", + "body": "Dodaje własne pozycje do palety poleceń (Ctrl+K)." + }, + "ui_app_page": { + "title": "Własne strony", + "body": "Dodaje do aplikacji własne strony, każdą z przyciskiem na pasku nawigacyjnym." + }, + "http_proxy": { + "title": "Korzystanie z połączonych przez Ciebie kont", + "body": "Wysyła żądania przez serwer Bulwark z użyciem konta lub klucza skonfigurowanego przez Ciebie lub Twojego administratora. Wtyczka nigdy nie widzi hasła, ale może zrobić na tym serwerze wszystko, co może to konto." } }, "dialog": { diff --git a/locales/pt/common.json b/locales/pt/common.json index a3eba138d..3b168875e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4178,8 +4178,8 @@ "body": "Saber qual página está aberta e reagir aos links em que você clica." }, "ui_toolbar": { - "title": "Adicionar botões à barra de ferramentas da mensagem", - "body": "Mostrar os próprios botões acima de uma mensagem aberta." + "title": "Adicionar botões às barras de ferramentas", + "body": "Mostrar os próprios botões na barra de ferramentas das mensagens, em uma mensagem aberta e na barra superior." }, "ui_app_top_banner": { "title": "Mostrar um banner no topo", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Mostrar avisos nas mensagens", - "body": "Mostrar um aviso acima do texto de uma mensagem aberta." + "body": "Mostrar os próprios avisos e resumos acima, dentro e entre as partes de uma mensagem ou conversa aberta." }, "ui_email_footer": { "title": "Adicionar conteúdo abaixo das mensagens", @@ -4210,16 +4210,16 @@ "body": "Organizar sua caixa de entrada em abas como Atualizações ou Promoções." }, "ui_composer_toolbar": { - "title": "Adicionar botões ao editor", - "body": "Mostrar os próprios botões enquanto você escreve uma mensagem." + "title": "Adicionar ao editor", + "body": "Mostrar os próprios botões, itens de menu e seções enquanto você escreve uma mensagem." }, "ui_composer_sidebar": { "title": "Adicionar um painel ao editor", "body": "Mostrar o próprio painel ao lado da mensagem que você escreve." }, "ui_sidebar_widget": { - "title": "Adicionar uma caixa à barra lateral", - "body": "Mostrar o próprio conteúdo na barra lateral do e-mail." + "title": "Adicionar à barra lateral", + "body": "Mostrar as próprias caixas e seções na barra lateral do e-mail." }, "ui_settings_section": { "title": "Adicionar uma página de configurações", @@ -4238,8 +4238,8 @@ "body": "Reagir aos atalhos de teclado que ele define." }, "ui_calendar_action": { - "title": "Adicionar botões aos eventos", - "body": "Mostrar os próprios botões quando você cria ou edita um evento." + "title": "Adicionar à visualização do calendário", + "body": "Mostrar os próprios botões e seções nos eventos, e rótulos curtos e pontos nos dias do calendário." }, "ui_admin_page": { "title": "Adicionar uma página de administração", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Mostrar notificações", "body": "Mostrar notificações no seu dispositivo, ou dentro do aplicativo se você não as permitiu. Cada uma indica o nome do plugin." + }, + "ui_message_list": { + "title": "Adicionar à lista de mensagens", + "body": "Mostrar o próprio conteúdo acima da lista de mensagens, e botões nas mensagens e na barra das mensagens selecionadas." + }, + "ui_contacts": { + "title": "Adicionar à visualização de contatos", + "body": "Mostrar as próprias seções nos detalhes de um contato e botões na lista de contatos." + }, + "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." + }, + "ui_files": { + "title": "Adicionar à visualização de arquivos", + "body": "Mostrar as próprias seções na pré-visualização de arquivos e itens no menu de um arquivo." + }, + "ui_commands": { + "title": "Adicionar comandos", + "body": "Adicionar os próprios itens à paleta de comandos (Ctrl+K)." + }, + "ui_app_page": { + "title": "Adicionar páginas", + "body": "Adicionar as próprias páginas ao aplicativo, cada uma com um botão na barra de navegação." + }, + "http_proxy": { + "title": "Usar contas que você conecta", + "body": "Enviar solicitações pelo servidor do Bulwark com uma conta ou chave configurada por você ou pelo seu administrador. O plugin nunca vê a senha, mas pode fazer tudo o que essa conta pode fazer no próprio servidor." } }, "dialog": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 994fe007c..3fef212eb 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4178,8 +4178,8 @@ "body": "Știe ce pagină este deschisă și reacționează la linkurile pe care faceți clic." }, "ui_toolbar": { - "title": "Adăugarea de butoane în bara de instrumente a mesajului", - "body": "Afișează butoane proprii deasupra unui mesaj deschis." + "title": "Adăugarea de butoane în barele de instrumente", + "body": "Afișează butoane proprii în bara de instrumente a mesajelor, pe un mesaj deschis și în bara de sus." }, "ui_app_top_banner": { "title": "Afișarea unui banner în partea de sus", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Afișarea de notificări pe mesaje", - "body": "Afișează o notificare deasupra textului unui mesaj deschis." + "body": "Afișează notificări și rezumate proprii deasupra, în interiorul și între părțile unui mesaj sau ale unei conversații deschise." }, "ui_email_footer": { "title": "Adăugarea de conținut sub mesaje", @@ -4210,16 +4210,16 @@ "body": "Sortează mesajele primite în file precum Actualizări sau Promoții." }, "ui_composer_toolbar": { - "title": "Adăugarea de butoane în fereastra de redactare", - "body": "Afișează butoane proprii în timp ce scrieți un mesaj." + "title": "Adăugarea în fereastra de redactare", + "body": "Afișează butoane, opțiuni de meniu și secțiuni proprii în timp ce scrieți un mesaj." }, "ui_composer_sidebar": { "title": "Adăugarea unui panou în fereastra de redactare", "body": "Afișează un panou propriu lângă mesajul pe care îl scrieți." }, "ui_sidebar_widget": { - "title": "Adăugarea unei casete în bara laterală", - "body": "Afișează conținut propriu în bara laterală a e-mailului." + "title": "Adăugarea în bara laterală", + "body": "Afișează casete și secțiuni proprii în bara laterală a e-mailului." }, "ui_settings_section": { "title": "Adăugarea unei pagini de setări", @@ -4238,8 +4238,8 @@ "body": "Reacționează la comenzile rapide de la tastatură pe care le definește." }, "ui_calendar_action": { - "title": "Adăugarea de butoane la evenimente", - "body": "Afișează butoane proprii când creați sau editați un eveniment." + "title": "Adăugarea în vizualizarea calendarului", + "body": "Afișează butoane și secțiuni proprii la evenimente, precum și etichete scurte și puncte la zilele din calendar." }, "ui_admin_page": { "title": "Adăugarea unei pagini de administrare", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Afișarea notificărilor", "body": "Afișează notificări pe dispozitiv sau în aplicație, dacă nu le-ați permis. Fiecare indică numele pluginului." + }, + "ui_message_list": { + "title": "Adăugarea în lista de mesaje", + "body": "Afișează conținut propriu deasupra listei de mesaje, precum și butoane pe mesaje și în bara mesajelor selectate." + }, + "ui_contacts": { + "title": "Adăugarea în vizualizarea contactelor", + "body": "Afișează secțiuni proprii în detaliile unui contact și butoane în lista de contacte." + }, + "ui_tasks": { + "title": "Adăugarea în vizualizarea sarcinilor", + "body": "Afișează secțiuni proprii în detaliile unei sarcini și butoane în lista de sarcini." + }, + "ui_files": { + "title": "Adăugarea în vizualizarea fișierelor", + "body": "Afișează secțiuni proprii în previzualizarea fișierelor și opțiuni în meniul unui fișier." + }, + "ui_commands": { + "title": "Adăugarea de comenzi", + "body": "Adaugă opțiuni proprii în paleta de comenzi (Ctrl+K)." + }, + "ui_app_page": { + "title": "Adăugarea de pagini", + "body": "Adaugă pagini proprii în aplicație, fiecare cu un buton în bara de navigare." + }, + "http_proxy": { + "title": "Folosirea conturilor pe care le conectați", + "body": "Trimite cereri prin serverul Bulwark cu un cont sau o cheie configurate de dvs. sau de administratorul dvs. Pluginul nu vede niciodată parola, dar poate face tot ce poate face acel cont pe serverul său." } }, "dialog": { diff --git a/locales/ru/common.json b/locales/ru/common.json index f564ae44c..9e611a41b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4178,8 +4178,8 @@ "body": "Знать, какая страница открыта, и реагировать на ссылки, по которым вы нажимаете." }, "ui_toolbar": { - "title": "Добавлять кнопки на панель инструментов письма", - "body": "Показывать свои кнопки над открытым письмом." + "title": "Добавлять кнопки на панели инструментов", + "body": "Показывать свои кнопки на панели инструментов писем, в открытом письме и на верхней панели." }, "ui_app_top_banner": { "title": "Показывать баннер сверху", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Показывать уведомления в письмах", - "body": "Показывать уведомление над текстом открытого письма." + "body": "Показывать свои уведомления и сводки над частями открытого письма или беседы, внутри них и между ними." }, "ui_email_footer": { "title": "Добавлять содержимое под письмами", @@ -4210,16 +4210,16 @@ "body": "Распределять входящие письма по вкладкам, например «Обновления» или «Промоакции»." }, "ui_composer_toolbar": { - "title": "Добавлять кнопки в редактор", - "body": "Показывать свои кнопки, пока вы пишете письмо." + "title": "Добавлять элементы в редактор", + "body": "Показывать свои кнопки, пункты меню и разделы, пока вы пишете письмо." }, "ui_composer_sidebar": { "title": "Добавлять панель в редактор", "body": "Показывать свою панель рядом с письмом, которое вы пишете." }, "ui_sidebar_widget": { - "title": "Добавлять блок на боковую панель", - "body": "Показывать своё содержимое на боковой панели почты." + "title": "Добавлять элементы на боковую панель", + "body": "Показывать свои блоки и разделы на боковой панели почты." }, "ui_settings_section": { "title": "Добавлять страницу настроек", @@ -4238,8 +4238,8 @@ "body": "Реагировать на заданные им сочетания клавиш." }, "ui_calendar_action": { - "title": "Добавлять кнопки к событиям", - "body": "Показывать свои кнопки, когда вы создаёте или редактируете событие." + "title": "Добавлять элементы в интерфейс календаря", + "body": "Показывать свои кнопки и разделы в событиях, а также короткие метки и точки на днях календаря." }, "ui_admin_page": { "title": "Добавлять страницу администрирования", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Показывать уведомления", "body": "Показывать уведомления на вашем устройстве или в приложении, если вы их не разрешили. В каждом указано название плагина." + }, + "ui_message_list": { + "title": "Добавлять элементы в список сообщений", + "body": "Показывать своё содержимое над списком сообщений, а также кнопки на письмах и на панели выбранных писем." + }, + "ui_contacts": { + "title": "Добавлять элементы в интерфейс контактов", + "body": "Показывать свои разделы в карточке контакта и кнопки в списке контактов." + }, + "ui_tasks": { + "title": "Добавлять элементы в интерфейс задач", + "body": "Показывать свои разделы в сведениях о задаче и кнопки в списке задач." + }, + "ui_files": { + "title": "Добавлять элементы в интерфейс файлов", + "body": "Показывать свои разделы в предпросмотре файла и пункты в меню файла." + }, + "ui_commands": { + "title": "Добавлять команды", + "body": "Добавлять свои пункты в палитру команд (Ctrl+K)." + }, + "ui_app_page": { + "title": "Добавлять страницы", + "body": "Добавлять в приложение свои страницы, каждую с кнопкой на панели навигации." + }, + "http_proxy": { + "title": "Использовать подключённые вами аккаунты", + "body": "Отправлять запросы через сервер Bulwark с аккаунтом или ключом, который настроили вы или ваш администратор. Плагин никогда не видит пароль, но может делать на этом сервере всё, что может этот аккаунт." } }, "dialog": { diff --git a/locales/sk/common.json b/locales/sk/common.json index b39fa2406..d9ad29d8e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4178,8 +4178,8 @@ "body": "Vie, ktorá stránka je otvorená, a reaguje na odkazy, na ktoré kliknete." }, "ui_toolbar": { - "title": "Pridávať tlačidlá na panel nástrojov správy", - "body": "Zobrazuje vlastné tlačidlá nad otvorenou správou." + "title": "Pridávať tlačidlá na panely nástrojov", + "body": "Zobrazuje vlastné tlačidlá na paneli nástrojov správ, pri otvorenej správe a v hornej lište." }, "ui_app_top_banner": { "title": "Zobrazovať banner hore", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Zobrazovať oznámenia pri správach", - "body": "Zobrazuje oznámenie nad textom otvorenej správy." + "body": "Zobrazuje vlastné oznámenia a zhrnutia nad časťami otvorenej správy alebo konverzácie, v nich a medzi nimi." }, "ui_email_footer": { "title": "Pridávať obsah pod správy", @@ -4210,16 +4210,16 @@ "body": "Rozdeľuje Doručené do kariet, napríklad Aktualizácie alebo Akcie." }, "ui_composer_toolbar": { - "title": "Pridávať tlačidlá do editora správy", - "body": "Zobrazuje vlastné tlačidlá, keď píšete správu." + "title": "Pridávať obsah do editora správy", + "body": "Zobrazuje vlastné tlačidlá, položky ponuky a sekcie, keď píšete správu." }, "ui_composer_sidebar": { "title": "Pridávať panel do editora správy", "body": "Zobrazuje vlastný panel vedľa správy, ktorú píšete." }, "ui_sidebar_widget": { - "title": "Pridávať pole do postranného panela", - "body": "Zobrazuje vlastný obsah v postrannom paneli pošty." + "title": "Pridávať obsah do postranného panela", + "body": "Zobrazuje vlastné polia a sekcie v postrannom paneli pošty." }, "ui_settings_section": { "title": "Pridávať stránku nastavení", @@ -4238,8 +4238,8 @@ "body": "Reaguje na klávesové skratky, ktoré sám definuje." }, "ui_calendar_action": { - "title": "Pridávať tlačidlá k udalostiam", - "body": "Zobrazuje vlastné tlačidlá, keď vytvárate alebo upravujete udalosť." + "title": "Pridávať obsah do sekcie Kalendár", + "body": "Zobrazuje vlastné tlačidlá a sekcie pri udalostiach a krátke štítky a bodky pri dňoch v kalendári." }, "ui_admin_page": { "title": "Pridávať stránku administrácie", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Zobrazovať upozornenia", "body": "Zobrazuje upozornenia vo vašom zariadení alebo v aplikácii, ak ste ich nepovolili. Každé uvádza názov doplnku." + }, + "ui_message_list": { + "title": "Pridávať obsah do zoznamu správ", + "body": "Zobrazuje vlastný obsah nad zoznamom správ a tlačidlá pri správach a na paneli nástrojov pre vybrané správy." + }, + "ui_contacts": { + "title": "Pridávať obsah do sekcie Kontakty", + "body": "Zobrazuje vlastné sekcie v podrobnostiach kontaktu a tlačidlá v zozname kontaktov." + }, + "ui_tasks": { + "title": "Pridávať obsah do sekcie Úlohy", + "body": "Zobrazuje vlastné sekcie v podrobnostiach úlohy a tlačidlá v zozname úloh." + }, + "ui_files": { + "title": "Pridávať obsah do sekcie Súbory", + "body": "Zobrazuje vlastné sekcie v náhľade súboru a položky v ponuke súboru." + }, + "ui_commands": { + "title": "Pridávať príkazy", + "body": "Pridáva vlastné položky do palety príkazov (Ctrl+K)." + }, + "ui_app_page": { + "title": "Pridávať stránky", + "body": "Pridáva do aplikácie vlastné stránky, každú s tlačidlom na navigačnej lište." + }, + "http_proxy": { + "title": "Používať účty, ktoré pripojíte", + "body": "Odosiela požiadavky cez server Bulwarku pomocou účtu alebo kľúča, ktorý ste nastavili vy alebo váš správca. Doplnok nikdy nevidí heslo, ale môže na príslušnom serveri robiť čokoľvek, čo môže tento účet." } }, "dialog": { diff --git a/locales/tr/common.json b/locales/tr/common.json index bc3ec480b..ad0c5dbf6 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4178,8 +4178,8 @@ "body": "Hangi sayfanın açık olduğunu bilme ve tıkladığınız bağlantılara tepki verme." }, "ui_toolbar": { - "title": "İleti araç çubuğuna düğme ekleme", - "body": "Açık bir iletinin üstünde kendi düğmelerini gösterme." + "title": "Araç çubuklarına düğme ekleme", + "body": "İleti araç çubuğunda, açık bir iletide ve üst çubukta kendi düğmelerini gösterme." }, "ui_app_top_banner": { "title": "Üstte bir başlık bandı gösterme", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "İletilerde bildirim gösterme", - "body": "Açık bir iletinin metninin üstünde bildirim gösterme." + "body": "Açık bir iletinin veya konuşmanın bölümlerinin üstünde, içinde ve arasında kendi bildirimlerini ve özetlerini gösterme." }, "ui_email_footer": { "title": "İletilerin altına içerik ekleme", @@ -4210,16 +4210,16 @@ "body": "Gelen kutunuzu Güncellemeler veya Promosyonlar gibi sekmelere ayırma." }, "ui_composer_toolbar": { - "title": "İleti düzenleyiciye düğme ekleme", - "body": "Siz ileti yazarken kendi düğmelerini gösterme." + "title": "İleti düzenleyiciye öğe ekleme", + "body": "Siz ileti yazarken kendi düğmelerini, menü öğelerini ve bölümlerini gösterme." }, "ui_composer_sidebar": { "title": "İleti düzenleyiciye panel ekleme", "body": "Yazdığınız iletinin yanında kendi panelini gösterme." }, "ui_sidebar_widget": { - "title": "Kenar çubuğuna kutu ekleme", - "body": "Posta kenar çubuğunda kendi içeriğini gösterme." + "title": "Kenar çubuğuna öğe ekleme", + "body": "Posta kenar çubuğunda kendi kutularını ve bölümlerini gösterme." }, "ui_settings_section": { "title": "Ayarlar sayfası ekleme", @@ -4238,8 +4238,8 @@ "body": "Tanımladığı klavye kısayollarına tepki verme." }, "ui_calendar_action": { - "title": "Etkinliklere düğme ekleme", - "body": "Bir etkinlik oluştururken veya düzenlerken kendi düğmelerini gösterme." + "title": "Takvim görünümüne öğe ekleme", + "body": "Etkinliklerde kendi düğmelerini ve bölümlerini, takvim günlerinde kısa etiketler ve noktalar gösterme." }, "ui_admin_page": { "title": "Yönetim sayfası ekleme", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Bildirim gösterme", "body": "Cihazınızda bildirim gösterme ya da bildirimlere izin vermediyseniz uygulama içinde gösterme. Her bildirimde eklentinin adı yer alır." + }, + "ui_message_list": { + "title": "İleti listesine öğe ekleme", + "body": "İleti listesinin üstünde kendi içeriğini, iletilerde ve seçili iletilerin araç çubuğunda düğmeler gösterme." + }, + "ui_contacts": { + "title": "Kişiler görünümüne öğe ekleme", + "body": "Bir kişinin ayrıntılarında kendi bölümlerini, kişi listesinde düğmeler gösterme." + }, + "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." + }, + "ui_files": { + "title": "Dosyalar görünümüne öğe ekleme", + "body": "Dosya önizlemesinde kendi bölümlerini, bir dosyanın menüsünde öğeler gösterme." + }, + "ui_commands": { + "title": "Komut ekleme", + "body": "Komut paletine (Ctrl+K) kendi öğelerini ekleme." + }, + "ui_app_page": { + "title": "Sayfa ekleme", + "body": "Uygulamaya kendi sayfalarını ekleme, her biri için gezinme çubuğunda bir düğmeyle." + }, + "http_proxy": { + "title": "Bağladığınız hesapları kullanma", + "body": "Sizin veya yöneticinizin ayarladığı bir hesap ya da anahtarla Bulwark sunucusu üzerinden istek gönderme. Eklenti parolayı hiçbir zaman görmez, ancak o hesabın kendi sunucusunda yapabildiği her şeyi yapabilir." } }, "dialog": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 77efcff68..c9d02417d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4178,8 +4178,8 @@ "body": "Знати, яку сторінку відкрито, і реагувати на посилання, які ви натискаєте." }, "ui_toolbar": { - "title": "Додавати кнопки на панель інструментів листа", - "body": "Показувати власні кнопки над відкритим листом." + "title": "Додавати кнопки на панелі інструментів", + "body": "Показувати власні кнопки на панелі інструментів листів, у відкритому листі та на верхній панелі." }, "ui_app_top_banner": { "title": "Показувати банер угорі", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "Показувати сповіщення в листах", - "body": "Показувати сповіщення над текстом відкритого листа." + "body": "Показувати власні сповіщення та підсумки над частинами відкритого листа або розмови, усередині них і між ними." }, "ui_email_footer": { "title": "Додавати вміст під листами", @@ -4210,16 +4210,16 @@ "body": "Розподіляти вхідні листи за вкладками, наприклад «Оновлення» або «Акції»." }, "ui_composer_toolbar": { - "title": "Додавати кнопки в редактор листа", - "body": "Показувати власні кнопки, поки ви пишете лист." + "title": "Додавати елементи в редактор листа", + "body": "Показувати власні кнопки, пункти меню й розділи, поки ви пишете лист." }, "ui_composer_sidebar": { "title": "Додавати панель у редактор листа", "body": "Показувати власну панель поруч із листом, який ви пишете." }, "ui_sidebar_widget": { - "title": "Додавати блок на бічну панель", - "body": "Показувати власний вміст на бічній панелі пошти." + "title": "Додавати елементи на бічну панель", + "body": "Показувати власні блоки й розділи на бічній панелі пошти." }, "ui_settings_section": { "title": "Додавати сторінку налаштувань", @@ -4238,8 +4238,8 @@ "body": "Реагувати на сполучення клавіш, які він визначає." }, "ui_calendar_action": { - "title": "Додавати кнопки до подій", - "body": "Показувати власні кнопки, коли ви створюєте або редагуєте подію." + "title": "Додавати елементи в інтерфейс календаря", + "body": "Показувати власні кнопки й розділи в подіях, а також короткі мітки й крапки на днях календаря." }, "ui_admin_page": { "title": "Додавати сторінку адміністрування", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "Показувати сповіщення", "body": "Показувати сповіщення на вашому пристрої або в програмі, якщо ви їх не дозволили. У кожному вказано назву плагіна." + }, + "ui_message_list": { + "title": "Додавати елементи до списку повідомлень", + "body": "Показувати власний вміст над списком повідомлень, а також кнопки на листах і на панелі вибраних листів." + }, + "ui_contacts": { + "title": "Додавати елементи в інтерфейс контактів", + "body": "Показувати власні розділи в картці контакту й кнопки в списку контактів." + }, + "ui_tasks": { + "title": "Додавати елементи в інтерфейс завдань", + "body": "Показувати власні розділи в подробицях завдання й кнопки в списку завдань." + }, + "ui_files": { + "title": "Додавати елементи в інтерфейс файлів", + "body": "Показувати власні розділи в попередньому перегляді файлу й пункти в меню файлу." + }, + "ui_commands": { + "title": "Додавати команди", + "body": "Додавати власні пункти до палітри команд (Ctrl+K)." + }, + "ui_app_page": { + "title": "Додавати сторінки", + "body": "Додавати до програми власні сторінки, кожну з кнопкою на панелі навігації." + }, + "http_proxy": { + "title": "Використовувати підключені вами облікові записи", + "body": "Надсилати запити через сервер Bulwark з обліковим записом або ключем, який налаштували ви або ваш адміністратор. Плагін ніколи не бачить пароль, але може робити на цьому сервері все, що може цей обліковий запис." } }, "dialog": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 61877dc07..03ce06f43 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4178,8 +4178,8 @@ "body": "得知目前開啟的頁面,並對您點選的連結做出回應。" }, "ui_toolbar": { - "title": "在郵件工具列新增按鈕", - "body": "在開啟的郵件上方顯示它自己的按鈕。" + "title": "在工具列新增按鈕", + "body": "在郵件工具列、開啟的郵件和頂端列顯示它自己的按鈕。" }, "ui_app_top_banner": { "title": "在頂端顯示橫幅", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "在郵件上顯示通知", - "body": "在開啟的郵件內文上方顯示一則通知。" + "body": "在開啟的郵件或對話串各部分的上方、內部和之間顯示它自己的通知和摘要。" }, "ui_email_footer": { "title": "在郵件下方新增內容", @@ -4210,16 +4210,16 @@ "body": "將收件匣分成「最新快訊」或「促銷內容」等分頁。" }, "ui_composer_toolbar": { - "title": "在撰寫視窗新增按鈕", - "body": "在您撰寫郵件時顯示它自己的按鈕。" + "title": "新增到撰寫視窗", + "body": "在您撰寫郵件時顯示它自己的按鈕、選單項目和區塊。" }, "ui_composer_sidebar": { "title": "在撰寫視窗新增面板", "body": "在您撰寫的郵件旁邊顯示它自己的面板。" }, "ui_sidebar_widget": { - "title": "在側邊欄新增方塊", - "body": "在郵件側邊欄顯示它自己的內容。" + "title": "新增到側邊欄", + "body": "在郵件側邊欄顯示它自己的方塊和區塊。" }, "ui_settings_section": { "title": "新增設定頁面", @@ -4238,8 +4238,8 @@ "body": "回應它定義的鍵盤快速鍵。" }, "ui_calendar_action": { - "title": "為活動新增按鈕", - "body": "在您建立或編輯活動時顯示它自己的按鈕。" + "title": "新增到行事曆畫面", + "body": "在活動上顯示它自己的按鈕和區塊,並在行事曆日期上顯示簡短標籤和圓點。" }, "ui_admin_page": { "title": "新增管理頁面", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "顯示通知", "body": "在您的裝置上顯示通知;如果您未允許通知,則在應用程式內顯示。每則通知都會註明外掛程式名稱。" + }, + "ui_message_list": { + "title": "新增到郵件清單", + "body": "在郵件清單上方顯示它自己的內容,並在郵件上和所選郵件的工具列顯示按鈕。" + }, + "ui_contacts": { + "title": "新增到聯絡人畫面", + "body": "在聯絡人詳細資料中顯示它自己的區塊,並在聯絡人清單中顯示按鈕。" + }, + "ui_tasks": { + "title": "新增到工作畫面", + "body": "在工作詳細資料中顯示它自己的區塊,並在工作清單中顯示按鈕。" + }, + "ui_files": { + "title": "新增到檔案畫面", + "body": "在檔案預覽中顯示它自己的區塊,並在檔案選單中顯示項目。" + }, + "ui_commands": { + "title": "新增指令", + "body": "在指令面板(Ctrl+K)新增它自己的項目。" + }, + "ui_app_page": { + "title": "新增頁面", + "body": "在應用程式中新增它自己的頁面,每個頁面在導覽列都有一個按鈕。" + }, + "http_proxy": { + "title": "使用您連線的帳號", + "body": "使用您或管理員設定的帳號或金鑰,透過 Bulwark 的伺服器傳送要求。外掛程式永遠看不到密碼,但可以在該帳號所屬的伺服器上執行該帳號能做的任何動作。" } }, "dialog": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 5c5aed61b..8e42b7e40 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4178,8 +4178,8 @@ "body": "了解当前打开的页面,并对您点击的链接做出响应。" }, "ui_toolbar": { - "title": "在邮件工具栏中添加按钮", - "body": "在打开的邮件上方显示它自己的按钮。" + "title": "在工具栏中添加按钮", + "body": "在邮件工具栏、打开的邮件和顶部栏中显示它自己的按钮。" }, "ui_app_top_banner": { "title": "在顶部显示横幅", @@ -4187,7 +4187,7 @@ }, "ui_email_banner": { "title": "在邮件上显示通知", - "body": "在打开的邮件正文上方显示一条通知。" + "body": "在打开的邮件或会话各部分的上方、内部和之间显示它自己的通知和摘要。" }, "ui_email_footer": { "title": "在邮件下方添加内容", @@ -4210,16 +4210,16 @@ "body": "将收件箱分为“动态”或“推广”等标签页。" }, "ui_composer_toolbar": { - "title": "在撰写窗口中添加按钮", - "body": "在您撰写邮件时显示它自己的按钮。" + "title": "添加到撰写窗口", + "body": "在您撰写邮件时显示它自己的按钮、菜单项和区块。" }, "ui_composer_sidebar": { "title": "在撰写窗口中添加面板", "body": "在您撰写的邮件旁边显示它自己的面板。" }, "ui_sidebar_widget": { - "title": "在侧边栏中添加方框", - "body": "在邮件侧边栏中显示它自己的内容。" + "title": "添加到侧边栏", + "body": "在邮件侧边栏中显示它自己的方框和区块。" }, "ui_settings_section": { "title": "添加设置页面", @@ -4238,8 +4238,8 @@ "body": "响应它定义的键盘快捷键。" }, "ui_calendar_action": { - "title": "为活动添加按钮", - "body": "在您创建或编辑活动时显示它自己的按钮。" + "title": "添加到日历界面", + "body": "在活动上显示它自己的按钮和区块,并在日历日期上显示简短标签和圆点。" }, "ui_admin_page": { "title": "添加管理页面", @@ -4256,6 +4256,34 @@ "ui_notifications": { "title": "显示通知", "body": "在您的设备上显示通知;如果您未允许通知,则在应用内显示。每条通知都会注明插件名称。" + }, + "ui_message_list": { + "title": "添加到邮件列表", + "body": "在邮件列表上方显示它自己的内容,并在邮件上和所选邮件的工具栏中显示按钮。" + }, + "ui_contacts": { + "title": "添加到联系人界面", + "body": "在联系人详情中显示它自己的区块,并在联系人列表中显示按钮。" + }, + "ui_tasks": { + "title": "添加到任务界面", + "body": "在任务详情中显示它自己的区块,并在任务列表中显示按钮。" + }, + "ui_files": { + "title": "添加到文件界面", + "body": "在文件预览中显示它自己的区块,并在文件菜单中显示条目。" + }, + "ui_commands": { + "title": "添加命令", + "body": "向命令面板(Ctrl+K)添加它自己的条目。" + }, + "ui_app_page": { + "title": "添加页面", + "body": "向应用添加它自己的页面,每个页面在导航栏中都有一个按钮。" + }, + "http_proxy": { + "title": "使用您连接的账户", + "body": "使用您或管理员设置的账户或密钥,通过 Bulwark 的服务器发送请求。插件永远看不到密码,但可以在该账户所属的服务器上执行该账户能做的任何操作。" } }, "dialog": { From 1ba53655e5f11d3166c7fbc2882d643697568897 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:37:24 +0200 Subject: [PATCH 081/147] fix: let identity hooks stop a plugin's change, send notification clicks to plugins that show notifications, and mark reading mail as high risk --- components/providers/plugin-app-events.tsx | 4 +++- lib/__tests__/plugin-host-settings-apis.test.ts | 11 +++++++++++ lib/plugin-hook-catalog.ts | 2 +- lib/plugin-sandbox/host-api-settings.ts | 9 ++++++--- lib/plugin-sandbox/permission-text.ts | 2 ++ lib/plugin-sandbox/runtime.tsx | 5 +++-- 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/components/providers/plugin-app-events.tsx b/components/providers/plugin-app-events.tsx index b0aff57dc..85592bf50 100644 --- a/components/providers/plugin-app-events.tsx +++ b/components/providers/plugin-app-events.tsx @@ -69,7 +69,9 @@ export function PluginAppEvents() { const onSwMessage = (e: MessageEvent) => { const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null; if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') { - void toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data }); + // App notifications (new mail, reminders): the tag only. Their data + // names messages and events, which this hook's permission doesn't cover. + void toastHooks.onNotificationClick.emit({ tag: msg.tag }); } }; diff --git a/lib/__tests__/plugin-host-settings-apis.test.ts b/lib/__tests__/plugin-host-settings-apis.test.ts index 3d4ac565b..e244cf96c 100644 --- a/lib/__tests__/plugin-host-settings-apis.test.ts +++ b/lib/__tests__/plugin-host-settings-apis.test.ts @@ -7,6 +7,7 @@ import { useTemplateStore } from '@/stores/template-store'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; +import { clearAllHooks, identityHooks } from '../plugin-hooks'; vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, @@ -132,6 +133,16 @@ describe('api.identities.update', () => { expect(client.updateIdentity).not.toHaveBeenCalled(); expect(HIGH_RISK_PERMISSIONS.has('identity:write')).toBe(true); }); + + it('lets another plugin stop the change, like one made in Settings', async () => { + identityHooks.onBeforeIdentityChange.register('guard', () => false); + try { + await expect(call(ALL, 'identities.update', 'id1', { name: 'x' })).resolves.toBeNull(); + expect(client.updateIdentity).not.toHaveBeenCalled(); + } finally { + clearAllHooks(); + } + }); }); describe('api.vacation', () => { diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 69d1c73aa..3f09f8f43 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -270,7 +270,7 @@ export const HOOK_CATALOG = { // ─── Notifications ───────────────────────────────────────── onToastShow: H('notifications', 'observer', 'ui:observe', '{ id, type, title, message }', 'when the app shows a toast'), onToastDismiss: H('notifications', 'observer', 'ui:observe', '{ id }', 'when a toast is dismissed'), - onNotificationClick: H('notifications', 'observer', 'email:read', '{ tag, data }', 'when a system notification is clicked'), + onNotificationClick: H('notifications', 'observer', 'ui:notifications', '{ tag }', 'when a notification the plugin showed, or one from the app, is clicked'), // ─── App lifecycle ───────────────────────────────────────── onAppReady: H('app', 'observer', 'app:lifecycle', '{ accountId }', 'once after the app has started and the mail data is loaded'), diff --git a/lib/plugin-sandbox/host-api-settings.ts b/lib/plugin-sandbox/host-api-settings.ts index 192080268..8aafffec1 100644 --- a/lib/plugin-sandbox/host-api-settings.ts +++ b/lib/plugin-sandbox/host-api-settings.ts @@ -9,7 +9,7 @@ import type { } from '../plugin-types'; import type { EmailAddress, Identity } from '../jmap/types'; import { useTemplateStore } from '@/stores/template-store'; -import { useIdentityStore } from '@/stores/identity-store'; +import { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; import { identityToView, templateToView, vacationToView } from '../plugin-projection'; import { sanitizeEmailHtml, sanitizePluginBodyHtml, sanitizeSignatureHtml } from '../email-sanitization'; @@ -112,7 +112,7 @@ const MAX_HTML_SIGNATURE = 100_000; * address itself can't be changed; an HTML signature is sanitised exactly * like one typed into Settings > Identities. */ -export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise { +export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise { const method = 'identities.update'; const identityId = readId(id, method); if (patch && typeof patch === 'object' && 'email' in patch) { @@ -141,7 +141,10 @@ export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise[2]); + if (!done) return null; useIdentityStore.getState().updateIdentityLocal(identityId, updates as Partial); return identityToView({ ...identity, ...updates } as Identity); } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index 90d8d7366..c5c7fe0cd 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -11,6 +11,8 @@ import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; */ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'crypto:full', + // email.get reads any message in the account, not only the open one. + 'email:read', 'email:send', 'email:raw-send', 'email:blob-read', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index ed574f1e7..af1e36d1d 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -693,10 +693,11 @@ function buildPluginApi(manifest: PluginManifest) { /** * Change the name, reply-to addresses or signatures of one identity. * The address can't be changed. An HTML signature is sanitised like - * one typed into Settings. Resolves to the identity as changed. + * one typed into Settings. Resolves to the identity as changed, or null + * when an onBeforeIdentityChange handler stopped it. */ update: (id: string, patch: IdentityPatch) => - callApi('identities.update', [id, patch]) as Promise, + callApi('identities.update', [id, patch]) as Promise, }, // Out-of-office reply (permissions: vacation:read / vacation:write). vacation: { From 8fbc8a1c1dc3fb768bdedfb572282a0f04c6fbd2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:43:22 +0200 Subject: [PATCH 082/147] feat: let plugins add frames above and in an empty message list and buttons to message rows and the selection toolbar --- .../mail-list-plugin-surfaces.test.tsx | 255 ++++++++++++++++++ components/email/email-context-menu.tsx | 20 ++ components/email/email-hover-actions.tsx | 23 +- components/email/email-list.tsx | 26 ++ components/email/mv-list-toolbar.tsx | 8 + components/email/thread-list-item.tsx | 2 + .../plugins/__tests__/fake-plugin-offer.ts | 43 +++ 7 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/mail-list-plugin-surfaces.test.tsx create mode 100644 components/plugins/__tests__/fake-plugin-offer.ts diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx new file mode 100644 index 000000000..e228eb913 --- /dev/null +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -0,0 +1,255 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; +import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer'; + +// Plugin surfaces of the message list: frames above the list and in the empty +// list, buttons on each row (hover, and the row menu), and buttons in the +// toolbar shown while messages are selected. + +vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({ + PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot, +})); +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/email/rules-menu', () => ({ RulesContextSubMenu: () => null })); +vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ + client: {}, identities: [], username: null, activeAccountId: 'acct-1', + getAllConnectedClients: () => new Map(), getClientForAccount: () => null, + })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined, accounts: [] }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const { EmailList } = await import('../email-list'); +const { MvListToolbar } = await import('../mv-list-toolbar'); +const { ThreadListItem } = await import('../thread-list-item'); +const { EmailContextMenu } = await import('../email-context-menu'); +const { useEmailStore } = await import('@/stores/email-store'); +const { useUIStore } = await import('@/stores/ui-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { groupEmailsByThread } = await import('@/lib/thread-utils'); + +const mail = (id: string): Email => ({ + id, + threadId: `thread-${id}`, + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2026-09-30T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: `Mail ${id}`, + hasAttachment: false, +}); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const dispatch = vi.fn().mockResolvedValue(undefined); +const initialEmailState = useEmailStore.getState(); + +beforeEach(() => { + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set() }); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never); +}); + +afterEach(() => { + clearOffers(); + setActionDispatcher(null); +}); + +describe('message-list-header', () => { + it('shows the plugin frame above the list with the folder', () => { + offerSlots('p1', ['message-list-header']); + render(); + + const frame = screen.getByTestId('slot-message-list-header'); + expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox' }); + // Above the scrolling list, not inside it. + expect(frame.closest('[data-tour="email-list"]')).toBeNull(); + }); + + it('draws nothing without a plugin', () => { + const { container } = render(); + expect(container.querySelector('[data-plugin-slot="message-list-header"]')).toBeNull(); + }); +}); + +describe('message-list-empty', () => { + it('shows the frame under the "no messages" text of an empty list', () => { + offerSlots('p1', ['message-list-empty']); + render(); + + const frame = screen.getByTestId('slot-message-list-empty'); + expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox', searching: false }); + const text = screen.getByText('no_emails_description'); + expect(text.compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('says when the empty list is a search', () => { + offerSlots('p1', ['message-list-empty']); + useEmailStore.setState({ searchQuery: 'invoice' }); + render(); + + expect(slotProps(screen.getByTestId('slot-message-list-empty')).searching).toBe(true); + }); + + it('is not there when the list has messages, or without a plugin', () => { + offerSlots('p1', ['message-list-empty']); + const { unmount } = render(); + expect(screen.queryByTestId('slot-message-list-empty')).toBeNull(); + unmount(); + clearOffers(); + render(); + expect(screen.queryByTestId('slot-message-list-empty')).toBeNull(); + }); +}); + +describe('message-bulk-actions', () => { + it('adds the plugin buttons to the selection toolbar and sends the selected ids', async () => { + const emails = [mail('a'), mail('b'), mail('c')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'c']) }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render(); + + const button = screen.getByRole('button', { name: 'Make tasks' }); + // In the toolbar that holds the app's own batch buttons. + const toolbar = screen.getByText('batch_actions.clear_selection', { selector: 'button' }).parentElement!; + expect(toolbar).toContainElement(button); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'c'] }); + }); + + it('has no plugin buttons without a selection or without a plugin', () => { + const emails = [mail('a')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a']) }); + const { unmount } = render(); + expect(document.querySelector('[data-plugin-actions="message-bulk-actions"]')).toBeNull(); + unmount(); + + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + useEmailStore.setState({ selectedEmailKeys: new Set() }); + render(); + expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull(); + }); + + it('adds them to the Mountain View toolbar too', async () => { + const emails = [mail('a'), mail('b')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render( {}} />); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); }); + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['b'] }); + }); + + it('leaves the Mountain View toolbar alone without a selection', () => { + useEmailStore.setState({ emails: [mail('a')] }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render( {}} />); + expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull(); + }); +}); + +describe('message-row-actions', () => { + const row = (email: Email) => { + const [thread] = groupEmailsByThread([email]); + return {}} onEmailSelect={() => {}} />; + }; + + it('adds the plugin buttons after the row\'s own hover buttons', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later', icon: 'clock' }])); + const onSelect = vi.fn(); + const [thread] = groupEmailsByThread([mail('a')]); + render( {}} onEmailSelect={onSelect} />); + + const button = screen.getByRole('button', { name: 'Later' }); + const star = screen.getByTestId('hover-action-star'); + expect(star.parentElement).toContainElement(button); + expect(star.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + // The click is the plugin's, not a row click that opens the message. + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('shows them even when the user turned off every quick action', () => { + useSettingsStore.setState({ hoverActions: [] } as never); + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + render(row(mail('a'))); + expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument(); + }); + + it('honours the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }])); + render(row(mail('a'))); + expect(screen.queryByRole('button', { name: 'Later' })).toBeNull(); + }); + + it('draws nothing without a plugin', () => { + render(row(mail('a'))); + expect(document.querySelector('[data-plugin-actions="message-row-actions"]')).toBeNull(); + }); + + const menu = (props: Partial> = {}) => render( + {})} + menuRef={{ current: null }} + mailboxes={mailboxes} + selectedMailbox="inbox" + currentMailboxRole="inbox" + {...props} + />, + ); + + it('puts them in the row menu, which is what a long press opens on phones', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + const onClose = vi.fn(); + menu({ onClose }); + + const item = screen.getByRole('menuitem', { name: 'Later' }); + await act(async () => { fireEvent.click(item); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => { + const { unmount } = menu(); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + unmount(); + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + menu({ isMultiSelect: true, selectedCount: 3 }); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + }); + + it('keeps the menu entries to the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['junk'] }])); + menu(); + expect(within(document.body).queryByRole('menuitem', { name: 'Later' })).toBeNull(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 3c732be1a..26ee9d45a 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -12,6 +12,7 @@ import { ContextMenuHeader, } from "@/components/ui/context-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; import { Reply, ReplyAll, @@ -266,6 +267,23 @@ export function EmailContextMenu({ // Stable, so the Rules entry does not re-resolve its account every render. const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]); + // The row's plugin buttons (message-row-actions). On phones the long press + // opens this menu, so it is where those buttons live there. + const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0; + const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]); + const rowPluginBlock = !showBatchActions && hasRowPluginActions ? ( + <> + + + + ) : null; + // The menu's entries, composed below in the default layout's order or, // under Mountain View, in Gmail's. const replyBlock = ( @@ -564,6 +582,7 @@ export function EmailContextMenu({ {findBlock} {permalinkBlock} + {rowPluginBlock} ) : ( <> @@ -581,6 +600,7 @@ export function EmailContextMenu({ {spamBlock} {readItem} + {rowPluginBlock} )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 6b1e835fe..82fd8c29c 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Email } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; import { useIsMobile } from "@/hooks/use-media-query"; import { TagPicker } from "@/components/email/tag-picker"; import { getEmailTagIds } from "@/lib/thread-utils"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; interface EmailHoverActionsProps { email: Email; @@ -27,6 +28,8 @@ interface EmailHoverActionsProps { onUndoSpam?: () => void; // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent). spamApplicable?: boolean; + /** Role of the folder the row is listed in; plugin buttons can be limited to some. */ + mailboxRole?: string | null; } const ACTION_CONFIG: Record state.hoverActions); const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); @@ -113,6 +117,9 @@ export function EmailHoverActions({ const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner); const t = useTranslations("settings.email_behavior.hover_actions"); const isMobile = useIsMobile(); + // Buttons plugins add to the row (message-row-actions), after the app's own. + const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length; + const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]); // The tag quick-action opens the same picker the context menu and the reading // pane use. It is portalled to the body because every row sits inside a @@ -159,7 +166,7 @@ export function EmailHoverActions({ const hoverBackgroundClassName = backgroundClassName; if (isMobile) return null; - if (hoverActions.length === 0) return null; + if (hoverActions.length === 0 && pluginActionCount === 0) return null; const handleAction = (e: React.MouseEvent, action: HoverAction) => { e.stopPropagation(); @@ -251,6 +258,18 @@ export function EmailHoverActions({ ); }); + if (pluginActionCount > 0) { + actionButtons.push( + , + ); + } const tagPicker = tagPickerPos && onSetTag diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 25bb80857..3c974a155 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -33,6 +33,8 @@ import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; import { getOwnAddresses } from "@/lib/filters/quick-rule-target"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { normalizeAddress } from "@/lib/filters/quick-rules"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions } from "@/components/plugins/plugin-actions"; interface EmailListProps { emails: Email[]; @@ -381,6 +383,19 @@ export function EmailList({ ); const hasSelection = selectedEmailKeys.size > 0; + // What plugin buttons in the selection toolbar act on: the JMAP ids of the + // selected messages, thread members included. + const selectedEmailIds = useMemo( + () => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id), + [emails, threadEmailsCache, selectedEmailKeys], + ); + const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]); + const listSlotProps = useMemo( + () => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }), + [selectedMailbox, effectiveMailboxRole], + ); + const isSearching = !!searchQuery || !isFilterEmpty(searchFilters); + const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]); const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -653,6 +668,14 @@ export function EmailList({ )} + {hasSelection && ( + + )}
)}
+ email.id) }} + mailboxRole={currentRole ?? null} + compact + className="shrink-0" + /> {tBatch("selected_messages", { count: selectionCount })} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 48697c5b2..5ed9264fb 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -783,6 +783,7 @@ const SingleEmailItem = React.forwardRef( onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + mailboxRole={currentMailboxRole ?? null} /> )}
@@ -1381,6 +1382,7 @@ export const ThreadListItem = React.forwardRef onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + mailboxRole={currentMailboxRole ?? null} /> )}
diff --git a/components/plugins/__tests__/fake-plugin-offer.ts b/components/plugins/__tests__/fake-plugin-offer.ts new file mode 100644 index 000000000..a11d8c685 --- /dev/null +++ b/components/plugins/__tests__/fake-plugin-offer.ts @@ -0,0 +1,43 @@ +// Test helpers: pretend a running plugin offers a frame slot, without booting +// a sandbox. Pair with a vi.mock of '@/components/plugins/plugin-iframe-slot' +// (FakeIframeSlot below) so the "iframe" shows the props it would receive. + +import React from 'react'; +import { register, deregister } from '@/lib/plugin-sandbox/registry'; +import type { ActivePlugin } from '@/lib/plugin-sandbox/registry'; +import type { SlotName } from '@/lib/plugin-types'; + +const offered = new Set(); + +/** Registers a fake plugin that offers the given slots. */ +export function offerSlots(pluginId: string, slots: string[]): void { + register({ + plugin: { id: pluginId, name: pluginId }, + code: '', + tier: 'untrusted', + background: {}, + slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false })), + hookDisposables: [], + } as unknown as ActivePlugin); + offered.add(pluginId); +} + +/** Removes every fake plugin registered with offerSlots. */ +export function clearOffers(): void { + for (const id of offered) deregister(id); + offered.clear(); +} + +/** Stand-in for PluginIframeSlot: a div carrying the slot name and its props as JSON. */ +export function FakeIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record }) { + return React.createElement('div', { + 'data-testid': `slot-${slot}`, + 'data-plugin': pluginId, + 'data-props': JSON.stringify(extraProps ?? {}), + }); +} + +/** The props the fake iframe of a slot received. */ +export function slotProps(el: HTMLElement): Record { + return JSON.parse(el.getAttribute('data-props') ?? '{}'); +} From d144a6e85ccb143f7cd0f87462d676468d83e79a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:43:40 +0200 Subject: [PATCH 083/147] feat: let plugins add sections to event details and the event form, and label calendar days --- .../calendar-plugin-surfaces.test.tsx | 186 ++++++++++++++++++ components/calendar/calendar-day-view.tsx | 2 + components/calendar/calendar-month-view.tsx | 2 + components/calendar/calendar-week-view.tsx | 2 + components/calendar/event-detail-popover.tsx | 5 + components/calendar/event-modal.tsx | 57 +++++- components/calendar/mini-calendar.tsx | 2 + components/calendar/plugin-day-label.tsx | 63 ++++++ .../plugins/__tests__/fake-slot-offer.tsx | 37 ++++ 9 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 components/calendar/__tests__/calendar-plugin-surfaces.test.tsx create mode 100644 components/calendar/plugin-day-label.tsx create mode 100644 components/plugins/__tests__/fake-slot-offer.tsx diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx new file mode 100644 index 000000000..06e07cf7d --- /dev/null +++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx @@ -0,0 +1,186 @@ +import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventModal } from '../event-modal'; +import { EventDetailPopover } from '../event-detail-popover'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import { CalendarMonthView } from '../calendar-month-view'; +import { MiniCalendar } from '../mini-calendar'; +import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Plugin surfaces in the calendar: a section in an event's details and in +// its form, and the labels and dots plugins put on days. + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const calendars = [ + { id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, +] as unknown as Calendar[]; + +const existing = { + id: 'e1', + uid: 'uid-1', + title: 'Planning', + start: '2026-10-12T10:00:00', + duration: 'PT1H', + timeZone: null, + showWithoutTime: false, + calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +afterEach(() => { + cleanup(); + clearSlotOffers(); + resetDayLabels(); +}); + +describe('calendar-event-detail', () => { + it('shows the event in the detail popover', () => { + offerSlots('p1', ['calendar-event-detail']); + render( + , + ); + + const frame = screen.getByTestId('plugin-frame-calendar-event-detail'); + expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' }); + }); + + it('shows the event in the modal view, and not in the form', () => { + offerSlots('p1', ['calendar-event-detail']); + render(); + + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' }); + + fireEvent.click(screen.getByText('events.edit')); + expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull(); + }); + + it('renders nothing when no plugin offers it', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('calendar-event-form', () => { + it('hands the plugin the form state of an edited event and follows the fields', async () => { + offerSlots('p1', ['calendar-event-form']); + render(); + expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull(); + + fireEvent.click(screen.getByText('events.edit')); + const frame = screen.getByTestId('plugin-frame-calendar-event-form'); + expect(framePropsOf(frame).event).toMatchObject({ + id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false, + }); + + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } }); + await waitFor(() => { + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' }); + }); + }); + + it('gives a new event an empty id', () => { + offerSlots('p1', ['calendar-event-form']); + render( + , + ); + + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ + id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [], + }); + }); + + it('renders nothing when no plugin offers it', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('calendar-day-header and mini-calendar-marks', () => { + const week = { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'week:2026-09-07', + rangeStart: new Date(2026, 8, 7), + rangeEnd: new Date(2026, 8, 13), + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAtTime: vi.fn(), + }; + + function headerOf(dayKey: string): HTMLElement { + const header = document.querySelector(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`); + if (!header) throw new Error(`no header for ${dayKey}`); + return header; + } + + it('puts a label under the day name in the week view', () => { + render(); + expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }])); + + const label = headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]'); + expect(label).toHaveTextContent('Feast day'); + expect(label?.title).toBe('A feast (Holidays)'); + expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + }); + + it('puts a label under the day name in the day view', () => { + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }])); + render(); + + expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day'); + }); + + it('shows the label in the month cell header on desktop only', () => { + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }])); + const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) }; + const { unmount } = render(); + + const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]'); + expect(labels).toHaveLength(1); + expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/); + unmount(); + + render(); + expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + }); + + it('marks days with a dot in the small month calendar', () => { + render( + , + ); + expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull(); + + act(() => setDayLabels('hol', 'Holidays', [ + { date: '2026-09-09', dot: true, title: 'A feast' }, + { date: '2026-09-10', text: 'Text only' }, + ])); + + const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]'); + expect(dots).toHaveLength(1); + expect(dots[0].closest('button')).toHaveTextContent('9'); + expect(dots[0].closest('button')).toHaveTextContent('A feast'); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index a111549e5..da1a8e942 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -20,6 +20,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { readableTextOn } from "@/lib/color-transform"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarDayViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -334,6 +335,7 @@ export function CalendarDayView({ > {format(day, "d")}
+
{hasAllDayArea && ( diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 31ba69a59..1164876b5 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useScrollWindow } from "@/hooks/use-scroll-window"; import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarMonthViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -379,6 +380,7 @@ export function CalendarMonthView({ > {formatDayNumber(day)} + {!isMobile && }
{isMobile && !showChips ? (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 2a342a034..d7f63c9d4 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { readableTextOn } from "@/lib/color-transform"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarWeekViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -436,6 +437,7 @@ export function CalendarWeekView({ > {format(day, "d")} +
); })} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index e7c573d4c..7a54d8f66 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { eventToView } from "@/lib/plugin-projection"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -145,6 +147,7 @@ export function EventDetailPopover({ // Invitations (Teams above all) leave virtualLocations empty and bury the // join URL in the description; surface it as the meeting link anyway. const meeting = useMemo(() => findMeetingLink(event), [event]); + const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]); const virtualLocation = meeting?.uri ?? null; // A URL opens as a link, a location that only names the service joins the @@ -580,6 +583,8 @@ export function EventDetailPopover({

)} + +
{/* Quick Note */} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 14b2d26aa..6a0b5b4f3 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -39,7 +39,7 @@ import { calendarHooks, type CalendarEventFormSavePatch, } from "@/lib/plugin-hooks"; -import type { ConflictWarning } from "@/lib/plugin-types"; +import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types"; import { eventToView } from "@/lib/plugin-projection"; export interface PendingEventPreview { @@ -446,6 +446,55 @@ export function EventModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); + // calendar-event-detail: the event as plugins see it, in the read views. + const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]); + + // calendar-event-form: the form's current state in the same shape. Typing + // would hand the plugin iframe new props on every key, so the view settles + // for half a second first. + const formEventView = useMemo(() => { + const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`; + const start = new Date(startStr); + let duration = event?.duration ?? "PT1H"; + if (!isNaN(start.getTime())) { + if (allDay) { + const inclusiveEnd = new Date(`${endDate}T00:00:00`); + if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd); + } else { + const end = new Date(`${endDate}T${endTime || "00:00"}:00`); + if (!isNaN(end.getTime())) duration = buildDuration(start, end); + } + } + const base = event ? eventToView(event) : null; + return { + id: event?.id ?? "", + uid: event?.uid ?? "", + calendarId, + title, + description, + start: startStr, + duration, + timeZone: getEffectiveTimeZone(), + utcStart: null, + utcEnd: null, + isAllDay: allDay, + location, + status: base?.status ?? "confirmed", + participants: attendees.map((a) => { + const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase()); + return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" }; + }), + isRecurring: recurrence !== "none", + keywords: base?.keywords ?? [], + }; + }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]); + const [settledFormEvent, setSettledFormEvent] = useState(formEventView); + useEffect(() => { + const timer = setTimeout(() => setSettledFormEvent(formEventView), 500); + return () => clearTimeout(timer); + }, [formEventView]); + const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]); + // Report live preview to parent for grid outline useEffect(() => { if (!onPreviewChange || isEdit) return; @@ -823,6 +872,8 @@ export function EventModal({
)} + + {eventDetailProps && }
@@ -1000,6 +1051,8 @@ export function EventModal({

)} + + {eventDetailProps && }
@@ -1347,6 +1400,8 @@ export function EventModal({
)} + +
diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index 03bc55dc8..cd7a9e8bc 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils"; import { displayNow } from "@/lib/timezone"; import type { CalendarEvent } from "@/lib/jmap/types"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { PluginDayLabel } from "./plugin-day-label"; type PickerView = "days" | "months" | "years"; @@ -199,6 +200,7 @@ export function MiniCalendar({ {hasEvent && !selected && !today && ( )} + ); diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx new file mode 100644 index 000000000..63ff9aa0f --- /dev/null +++ b/components/calendar/plugin-day-label.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { cn } from "@/lib/utils"; +import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels"; + +const NONE: RegisteredDayLabel[] = []; +const EMPTY_MAP = new Map(); + +/** The labels plugins put on one day (api.ui.setDayLabels). */ +export function useDayLabels(date: string): RegisteredDayLabel[] { + const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP); + return byDate.get(date) ?? NONE; +} + +function tooltip(labels: RegisteredDayLabel[]): string { + return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n"); +} + +interface PluginDayLabelProps { + /** + * calendar-day-header: the label text under a day's name (day, week and + * month views). mini-calendar-marks: a dot on the day in the small month + * calendar; the parent must be positioned. + */ + surface: "calendar-day-header" | "mini-calendar-marks"; + /** YYYY-MM-DD */ + date: string; + className?: string; +} + +/** What plugins put on a calendar day. Renders nothing without labels. */ +export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) { + const labels = useDayLabels(date); + + if (surface === "mini-calendar-marks") { + const marks = labels.filter((l) => l.dot); + if (marks.length === 0) return null; + return ( + <> +