From fd30fdcd27a43589d7fce466d46fbb9fede6ec67 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:14:23 +0100 Subject: [PATCH] feat: let plugins answer invitations, add filter rules, change many messages and change settings, with undo The AI assistant's "more actions" need host calls today's plugin API lacks. Area c of lib/plugin-sandbox/assistant adds them: - `calendar.respond(emailId, status)`, new permission calendar:respond: answers an invitation of the user's own account through lib/calendar-rsvp.ts, like the reader's banner - `calendar.deleteEvent` now returns an undo token for a single event; `calendar.undoDelete` creates it again from the host's copy - `filters.list/create/undo` over Settings > Filters' model; forward, redirect, reject, discard and keep are refused, and so are folders that are shared or not the account's own - `email.batch/undoBatch`: up to 200 messages through the store's batch functions, restore points first, one entry in the undo history - `vacation.get` also says whether the out-of-office forwards mail - `app.describeSettings`, `app.setSettings` and `app.undoSettings`, new permission settings:change, over the allow-list in lib/settings-schema.ts, refusing locked, hidden, restricted and gated settings when the change is made Undo tokens stay in the host for 30 minutes, bound to the plugin and the account. app.getSettings now shows every setting setSettings may change. settings:write's text is reworded to what it gates (tags). plugin-host-api-app-info gains a test; its other assertions are as they were. --- .../plugin-host-api-app-info.test.ts | 18 + lib/__tests__/plugin-host-batch.test.ts | 123 +++++ .../plugin-host-calendar-respond.test.ts | 131 +++++ lib/__tests__/plugin-host-filters.test.ts | 131 +++++ .../plugin-host-set-settings.test.ts | 117 ++++ .../plugin-permission-coverage.test.ts | 11 + lib/__tests__/settings-schema.test.ts | 231 ++++++++ lib/plugin-sandbox/assistant/c.batch.ts | 150 +++++ lib/plugin-sandbox/assistant/c.calendar.ts | 170 ++++++ lib/plugin-sandbox/assistant/c.filters.ts | 232 ++++++++ lib/plugin-sandbox/assistant/c.runtime.ts | 35 +- lib/plugin-sandbox/assistant/c.settings.ts | 271 +++++++++ lib/plugin-sandbox/assistant/c.ts | 67 ++- lib/plugin-sandbox/assistant/c.types.ts | 177 +++++- lib/plugin-sandbox/assistant/c.undo.ts | 57 ++ lib/plugin-sandbox/host-api-app.ts | 33 +- lib/plugin-sandbox/permission-text.ts | 4 + lib/settings-schema.ts | 519 ++++++++++++++++++ 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/et/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/th/common.json | 10 +- locales/tr/common.json | 10 +- locales/uk/common.json | 10 +- locales/vi/common.json | 10 +- locales/zh-TW/common.json | 10 +- locales/zh/common.json | 10 +- 48 files changed, 2731 insertions(+), 46 deletions(-) create mode 100644 lib/__tests__/plugin-host-batch.test.ts create mode 100644 lib/__tests__/plugin-host-calendar-respond.test.ts create mode 100644 lib/__tests__/plugin-host-filters.test.ts create mode 100644 lib/__tests__/plugin-host-set-settings.test.ts create mode 100644 lib/__tests__/settings-schema.test.ts create mode 100644 lib/plugin-sandbox/assistant/c.batch.ts create mode 100644 lib/plugin-sandbox/assistant/c.calendar.ts create mode 100644 lib/plugin-sandbox/assistant/c.filters.ts create mode 100644 lib/plugin-sandbox/assistant/c.settings.ts create mode 100644 lib/plugin-sandbox/assistant/c.undo.ts create mode 100644 lib/settings-schema.ts diff --git a/lib/__tests__/plugin-host-api-app-info.test.ts b/lib/__tests__/plugin-host-api-app-info.test.ts index 946126e77..94c79dad8 100644 --- a/lib/__tests__/plugin-host-api-app-info.test.ts +++ b/lib/__tests__/plugin-host-api-app-info.test.ts @@ -102,4 +102,22 @@ describe('api.app.getSettings', () => { expect(settings).not.toHaveProperty('pushRelayUrl'); expect(JSON.stringify(settings)).not.toContain('boss@example.org'); }); + + it('shares every setting a plugin may change, so it knows the value it would replace', async () => { + const { SETTINGS_SCHEMA } = await import('@/lib/settings-schema'); + const { SHARED_SETTINGS } = await import('../plugin-sandbox/host-api-app'); + const { useSettingsStore } = await import('@/stores/settings-store'); + const { useCalendarStore } = await import('@/stores/calendar-store'); + const shared = new Set(SHARED_SETTINGS); + const missing = Object.values(SETTINGS_SCHEMA).filter((e) => e.store === 'settings' && !shared.has(e.key)).map((e) => e.key); + expect(missing).toEqual([]); + + useSettingsStore.getState().resetToDefaults(); + useCalendarStore.setState({ viewMode: 'week' }); + const settings = await dispatchApiCall(plugin(['settings:read']), 'app.getSettings', []) as Record; + expect(settings.calendarView).toBe('week'); + // The reminder words come through whole (29 by default), or a proposal + // built on them would drop the rest. + expect(settings.attachmentReminderKeywords).toEqual(useSettingsStore.getState().attachmentReminderKeywords); + }); }); diff --git a/lib/__tests__/plugin-host-batch.test.ts b/lib/__tests__/plugin-host-batch.test.ts new file mode 100644 index 000000000..09e91f4f4 --- /dev/null +++ b/lib/__tests__/plugin-host-batch.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Mailbox } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; +import { clearUndoHistory, undoHistory, undoLatest } from '../undo-history'; +import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo'; + +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(id: string, perms: string[]): InstalledPlugin { + return { + id, name: id, version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} +const WRITER = plugin('assistant', ['email:write']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const news = { id: 'news', name: 'Newsletters', role: null } as unknown as Mailbox; +const trash = { id: 'trash', name: 'Trash', role: 'trash' } as Mailbox; +const sharedOut = { id: 'team', name: 'Team', role: null, shareWith: { 'u2': { mayReadItems: true } } } as unknown as Mailbox; +const otherAccount = { id: 'acc2:box', name: 'Theirs', isShared: true, accountId: 'acc2' } as unknown as Mailbox; + +const rows = [ + { id: 'm1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2026-10-01T10:00:00Z' }, + { id: 'm2', mailboxIds: { inbox: true, news: true }, keywords: {}, receivedAt: '2026-10-02T10:00:00Z' }, +]; +let getEmailFields: ReturnType; +let client: IJMAPClient; +const store = { + batchArchive: vi.fn(async () => true), + batchMoveToMailbox: vi.fn(async () => true), + batchMarkAsRead: vi.fn(async () => true), + batchSetTag: vi.fn(async () => true), + undoMove: vi.fn(async () => {}), +}; + +beforeEach(() => { + resetPluginRateLimits(); + clearUndoHistory(); + clearUndoTokens(); + for (const fn of Object.values(store)) fn.mockClear(); + getEmailFields = vi.fn(async (ids: string[]) => rows.filter((r) => ids.includes(r.id))); + client = { getAccountId: () => 'acc1', getEmailFields } as unknown as IJMAPClient; + useAuthStore.setState({ client }); + useEmailStore.setState({ mailboxes: [inbox, news, trash, sharedOut, otherAccount], ...store } as never); + useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never); +}); +afterEach(() => { + useAuthStore.setState({ client: null }); +}); + +describe('api.email.batch', () => { + it('archives the messages of the account, as the store archives them, with one Undo', async () => { + const result = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2', 'gone'], action: 'archive' }) as { done: number; failed: number; undoToken: string }; + expect(result).toMatchObject({ done: 2, failed: 1 }); + // From the signed-in account only, and through the store with an explicit target. + expect(getEmailFields).toHaveBeenCalledWith(['m1', 'm2', 'gone'], expect.any(Array), undefined); + expect(store.batchArchive).toHaveBeenCalledWith(client, expect.objectContaining({ emailIds: ['m1', 'm2'], client })); + // One entry in the app's undo history for the whole batch. + expect(undoHistory()).toHaveLength(1); + + await call(WRITER, 'email.undoBatch', result.undoToken); + // The restore points were taken before: every folder and the read state. + expect(store.undoMove).toHaveBeenCalledTimes(1); + expect(store.undoMove).toHaveBeenCalledWith(client, [{ + client, accountId: undefined, + points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }, { id: 'm2', mailboxIds: { inbox: true, news: true }, seen: false }], + }], []); + // Off the history too, and it works once. + expect(undoHistory()).toHaveLength(0); + await expect(call(WRITER, 'email.undoBatch', result.undoToken)).rejects.toThrow(/can't be undone/); + }); + + it('runs the undo once whether Ctrl+Z or the plugin comes first', async () => { + const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'news' }) as { undoToken: string }; + expect(store.batchMoveToMailbox).toHaveBeenCalledWith(client, 'news', expect.objectContaining({ emailIds: ['m1'] })); + undoLatest(); + await call(WRITER, 'email.undoBatch', undoToken); + expect(store.undoMove).toHaveBeenCalledTimes(1); + }); + + it('marks read only what changes and puts exactly that back', async () => { + const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2'], action: 'read' }) as { undoToken: string }; + expect(store.batchMarkAsRead).toHaveBeenLastCalledWith(client, true, { emailIds: ['m2'], client }); + await call(WRITER, 'email.undoBatch', undoToken); + expect(store.batchMarkAsRead).toHaveBeenLastCalledWith(client, false, { emailIds: ['m2'], client }); + }); + + it('tags through the store and takes the tag off again on undo', async () => { + const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2'], action: 'tag', tagId: 'work' }) as { undoToken: string }; + expect(store.batchSetTag).toHaveBeenLastCalledWith(client, 'work', true, expect.objectContaining({ emailIds: ['m1', 'm2'] })); + await call(WRITER, 'email.undoBatch', undoToken); + expect(store.batchSetTag).toHaveBeenLastCalledWith(client, 'work', false, expect.objectContaining({ emailIds: ['m1', 'm2'] })); + await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'tag', tagId: 'nope' })).rejects.toThrow(/not one of the user's tags/); + }); + + it('refuses shared folders, other accounts, the trash and more than 200 messages', async () => { + await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'team' })).rejects.toThrow(/only the user reads/); + await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'acc2:box' })).rejects.toThrow(/only the user reads/); + await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'trash' })).rejects.toThrow(/trash/); + await expect(call(WRITER, 'email.batch', { emailIds: Array.from({ length: 201 }, (_, i) => `m${i}`), action: 'read' })).rejects.toThrow(/at most 200/); + await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'delete' })).rejects.toThrow(/action must be/); + expect(store.batchMoveToMailbox).not.toHaveBeenCalled(); + }); + + it('keeps the undo to the plugin that made it, and needs email:write', async () => { + const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'archive' }) as { undoToken: string }; + await expect(call(plugin('other', ['email:write']), 'email.undoBatch', undoToken)).rejects.toThrow(/can't be undone/); + expect(store.undoMove).not.toHaveBeenCalled(); + await expect(call(plugin('reader', ['email:read']), 'email.batch', { emailIds: ['m1'], action: 'read' })).rejects.toThrow(/lacks permission "email:write"/); + }); +}); diff --git a/lib/__tests__/plugin-host-calendar-respond.test.ts b/lib/__tests__/plugin-host-calendar-respond.test.ts new file mode 100644 index 000000000..f287ed136 --- /dev/null +++ b/lib/__tests__/plugin-host-calendar-respond.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { CalendarEvent } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo'; + +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(id: string, perms: string[]): InstalledPlugin { + return { + id, name: id, version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} +const ASSISTANT = plugin('assistant', ['calendar:respond', 'calendar:write', 'vacation:read']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); + +const alice = { email: 'alice@example.org', name: 'Alice', roles: { owner: true } }; +const bob = { email: 'bob@example.org', name: 'Bob', roles: { attendee: true }, participationStatus: 'needs-action' }; +const invitation = { + id: 'inv', subject: 'Invitation: Team offsite', from: [{ email: 'alice@example.org', name: 'Alice' }], + attachments: [{ blobId: 'ics1', name: 'invite.ics', type: 'text/calendar', size: 100 }], + textBody: [], htmlBody: [], +}; +let parsed: Partial; +let client: IJMAPClient; +const store = { rsvpEvent: vi.fn(async () => true), importEvents: vi.fn(async () => 1), deleteEvent: vi.fn(async () => true), createEvent: vi.fn(async () => ({ id: 'again' })) }; + +beforeEach(() => { + clearUndoTokens(); + for (const fn of Object.values(store)) fn.mockClear(); + parsed = { + uid: 'u1', title: 'Team offsite', start: '2026-11-20T09:00:00', + organizerCalendarAddress: 'mailto:alice@example.org', participants: { p1: alice, p2: bob } as never, + }; + client = { + getAccountId: () => 'acc1', + getEmail: vi.fn(async (id: string) => (id === 'inv' ? invitation : null)), + parseCalendarEvents: vi.fn(async () => [parsed]), + fetchBlob: vi.fn(async () => new Blob(['BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR'])), + queryCalendarEvents: vi.fn(async () => []), + getCalendarEvent: vi.fn(async () => null), + supportsVacationResponse: () => true, + } as unknown as IJMAPClient; + useAuthStore.setState({ client, primaryIdentity: { email: 'bob@example.org' }, identities: [] } as never); + useEmailStore.setState({ emails: [], selectedEmail: null } as never); + useSettingsStore.setState({ calendarInvitationParsingEnabled: true } as never); + const imported = { id: 'e-new', uid: 'u1', participants: { p1: alice, p2: bob } }; + useCalendarStore.setState({ + supportsCalendar: true, calendars: [{ id: 'cal1', isDefault: true }], events: [], + ...store, + importEvents: vi.fn(async () => { useCalendarStore.setState({ events: [imported] } as never); return 1; }), + } as never); +}); +afterEach(() => { + useAuthStore.setState({ client: null }); +}); + +describe('api.calendar.respond', () => { + it('answers the invitation the way the banner does, so the server replies to the organiser', async () => { + const out = await call(ASSISTANT, 'calendar.respond', 'inv', 'accepted'); + expect(out).toEqual({ status: 'accepted', title: 'Team offsite', start: '2026-11-20T09:00:00', organizer: 'alice@example.org' }); + expect(useCalendarStore.getState().importEvents).toHaveBeenCalledWith(client, [parsed], 'cal1', { throwIfNoneImported: true }); + expect(store.rsvpEvent).toHaveBeenCalledWith(client, 'e-new', 'p2', 'accepted', { imip: 'mailto:alice@example.org' }); + }); + + it('refuses an event the user organises, a message that asks no answer, and while invitations are off', async () => { + // The user's own invitation, sent from their own address. + vi.mocked(client.getEmail).mockResolvedValueOnce({ ...invitation, from: [{ email: 'bob@example.org', name: 'Bob' }] } as never); + parsed.participants = { p1: { ...alice, email: 'bob@example.org' } } as never; + await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/organises/); + // Someone else's event whose sender doesn't match its organiser. + vi.mocked(client.getEmail).mockResolvedValueOnce({ ...invitation, from: [{ email: 'mallory@evil.example', name: 'Alice' }] } as never); + parsed.participants = { p1: alice, p2: bob } as never; + await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/may not come from/); + parsed.participants = { p1: alice, p2: bob } as never; + parsed.status = 'cancelled'; + vi.mocked(client.fetchBlob).mockResolvedValue(new Blob(['METHOD:CANCEL'])); + await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/not an invitation that asks for an answer/); + useSettingsStore.setState({ calendarInvitationParsingEnabled: false } as never); + await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/switched off/); + expect(store.rsvpEvent).not.toHaveBeenCalled(); + }); + + it('refuses mail of another account, a bad status and plugins without calendar:respond', async () => { + useEmailStore.setState({ emails: [{ id: 'inv', sourceClientAccountId: 'login1', sourceAccountId: 'acc2' }] } as never); + useAuthStore.setState({ getClientForAccount: () => client } as never); + await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/other accounts/); + await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'delegated')).rejects.toThrow(/status must be/); + await expect(call(plugin('cal', ['calendar:write']), 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/lacks permission "calendar:respond"/); + expect(store.rsvpEvent).not.toHaveBeenCalled(); + }); +}); + +describe('api.calendar.deleteEvent and undoDelete', () => { + it('deletes a single event and puts the host\'s copy back with the same uid', async () => { + vi.mocked(client.getCalendarEvent).mockResolvedValue({ id: 'e1', uid: 'u9', title: 'Dentist', start: '2026-11-03T10:00:00', calendarIds: { cal1: true }, utcStart: 'x' } as never); + const { undoToken } = await call(ASSISTANT, 'calendar.deleteEvent', 'e1') as { undoToken: string }; + expect(store.deleteEvent).toHaveBeenCalledWith(client, 'e1', false); + await expect(call(plugin('other', ['calendar:write']), 'calendar.undoDelete', undoToken)).rejects.toThrow(/can't be undone/); + expect(await call(ASSISTANT, 'calendar.undoDelete', undoToken)).toEqual({ id: 'again' }); + expect(store.createEvent).toHaveBeenCalledWith(client, { uid: 'u9', title: 'Dentist', start: '2026-11-03T10:00:00', calendarIds: { cal1: true } }, false); + }); + + it('gives no token for a repeating event, and takes no copy from the plugin', async () => { + vi.mocked(client.getCalendarEvent).mockResolvedValue({ id: 'e2', uid: 'u2', recurrenceRules: [{ frequency: 'weekly' }] } as never); + expect(await call(ASSISTANT, 'calendar.deleteEvent', 'e2')).toEqual({ undoToken: null }); + await expect(call(ASSISTANT, 'calendar.undoDelete', { uid: 'u2', title: 'Fake' })).rejects.toThrow(/undoToken must be a string/); + expect(store.createEvent).not.toHaveBeenCalled(); + }); +}); + +describe('api.vacation.get', () => { + it('says whether the out-of-office forwards mail', async () => { + const fetch = vi.fn(async () => { useVacationStore.setState({ isEnabled: false, forward: { enabled: true, to: 'x@example.com' }, error: null } as never); }); + useVacationStore.setState({ fetchVacationResponse: fetch } as never); + expect(await call(ASSISTANT, 'vacation.get')).toMatchObject({ isEnabled: false, forwardActive: true }); + useVacationStore.setState({ fetchVacationResponse: vi.fn(async () => { useVacationStore.setState({ forward: null } as never); }) } as never); + expect(await call(ASSISTANT, 'vacation.get')).toMatchObject({ forwardActive: false }); + }); +}); diff --git a/lib/__tests__/plugin-host-filters.test.ts b/lib/__tests__/plugin-host-filters.test.ts new file mode 100644 index 000000000..5cd495ad0 --- /dev/null +++ b/lib/__tests__/plugin-host-filters.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Mailbox } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { generateScript } from '../sieve/generator'; +import { parseScript } from '../sieve/parser'; +import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo'; + +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(id: string, perms: string[]): InstalledPlugin { + return { + id, name: id, version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} +const ASSISTANT = plugin('assistant', ['filters:read', 'filters:write']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox' }, + { id: 'news', name: 'Newsletters', role: null }, + { id: 'trash', name: 'Trash', role: 'trash' }, + { id: 'team', name: 'Team', role: null, shareWith: { u2: { mayReadItems: true } } }, + { id: 'acc2:box', name: 'Theirs', role: null, isShared: true, accountId: 'acc2' }, +] as unknown as Mailbox[]; + +/** A server with one filters script, in memory. */ +function server(initial: string) { + const state = { content: initial, active: 's1' as string | null }; + const client = { + supportsSieve: () => true, + getAccountId: () => 'acc1', + getSieveAccountId: () => 'acc1', + getSieveCapabilities: () => ({ sieveExtensions: ['fileinto', 'imap4flags', 'mailbox', 'mailboxid'] }), + getSieveScripts: vi.fn(async () => [{ id: 's1', name: 'filters', blobId: 'b1', isActive: state.active === 's1' }]), + getSieveScriptContent: vi.fn(async () => state.content), + updateSieveScript: vi.fn(async (_id: string, content: string) => { state.content = content; }), + createSieveScript: vi.fn(), + activateSieveScript: vi.fn(), + deactivateSieveScript: vi.fn(), + deleteSieveScript: vi.fn(), + getMailboxes: vi.fn(async () => mailboxes), + } as unknown as IJMAPClient; + return { client, state }; +} + +const RULE = { + name: 'Newsletters', + conditions: [{ field: 'from', op: 'contains', value: 'newsletter@shop.example' }], + actions: [{ type: 'move', mailboxId: 'news' }, { type: 'mark_read' }], +}; + +let srv: ReturnType; +beforeEach(() => { + clearUndoTokens(); + srv = server(generateScript([], undefined, {})); + useAuthStore.setState({ client: srv.client }); + useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never); + useFilterStore.setState({ selectedAccountId: null } as never); +}); +afterEach(() => { + useAuthStore.setState({ client: null }); +}); + +describe('api.filters', () => { + it('adds a rule at the top of the account\'s filters and takes it back', async () => { + const { id, undoToken } = await call(ASSISTANT, 'filters.create', RULE) as { id: string; undoToken: string }; + const rules = parseScript(srv.state.content).rules; + expect(rules).toHaveLength(1); + expect(rules[0]).toMatchObject({ + id, name: 'Newsletters', matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'newsletter@shop.example' }], + actions: [{ type: 'move', mailboxId: 'news', value: 'Newsletters' }, { type: 'mark_read' }], + }); + const listed = await call(ASSISTANT, 'filters.list') as { editable: boolean; rules: { name: string; actions: unknown[] }[] }; + expect(listed.editable).toBe(true); + expect(listed.rules[0].actions[0]).toEqual({ type: 'move', mailboxId: 'news', folder: 'Newsletters' }); + + await call(ASSISTANT, 'filters.undo', undoToken); + expect(parseScript(srv.state.content).rules).toHaveLength(0); + }); + + it('refuses forward, redirect, reject, discard and keep, whatever else the rule does', async () => { + for (const type of ['forward', 'redirect', 'reject', 'discard', 'keep']) { + const rule = { ...RULE, actions: [{ type: 'mark_read' }, { type, value: 'x@example.com' }] }; + await expect(call(ASSISTANT, 'filters.create', rule)).rejects.toThrow(new RegExp(`"${type}" is not allowed`)); + } + expect(srv.client.updateSieveScript).not.toHaveBeenCalled(); + }); + + it('files mail only into folders of the account that nobody else reads, never the trash', async () => { + for (const mailboxId of ['team', 'acc2:box', 'nope']) { + await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'copy', mailboxId }] })).rejects.toThrow(/only the user reads/); + } + await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'move', mailboxId: 'trash' }] })).rejects.toThrow(/trash/); + expect(srv.client.updateSieveScript).not.toHaveBeenCalled(); + }); + + it('checks the rule like the rule editor does', async () => { + await expect(call(ASSISTANT, 'filters.create', { ...RULE, name: ' ' })).rejects.toThrow(/name/); + await expect(call(ASSISTANT, 'filters.create', { ...RULE, conditions: [] })).rejects.toThrow(/conditions/); + await expect(call(ASSISTANT, 'filters.create', { ...RULE, conditions: [{ field: 'all', op: 'any' }] })).rejects.toThrow(/field must be/); + await expect(call(ASSISTANT, 'filters.create', { ...RULE, conditions: [{ field: 'header', op: 'contains', value: 'x', headerName: 'bad name' }] })).rejects.toThrow(/header name/); + await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'stop' }] })).rejects.toThrow(/besides stop/); + await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'add_label', tagId: 'nope' }] })).rejects.toThrow(/tags/); + }); + + it('leaves a hand-edited script alone', async () => { + // Metadata that doesn't parse makes the whole script opaque. + srv.state.content = '/* @metadata:begin\n{ not json\n@metadata:end */\nkeep;\n'; + const listed = await call(ASSISTANT, 'filters.list') as { editable: boolean }; + expect(listed.editable).toBe(false); + await expect(call(ASSISTANT, 'filters.create', RULE)).rejects.toThrow(/edited by hand/); + }); + + it('needs filters:write to add and keeps the undo to its plugin', async () => { + await expect(call(plugin('reader', ['filters:read']), 'filters.create', RULE)).rejects.toThrow(/lacks permission "filters:write"/); + const { undoToken } = await call(ASSISTANT, 'filters.create', RULE) as { undoToken: string }; + await expect(call(plugin('other', ['filters:write']), 'filters.undo', undoToken)).rejects.toThrow(/can't be undone/); + expect(parseScript(srv.state.content).rules).toHaveLength(1); + }); +}); diff --git a/lib/__tests__/plugin-host-set-settings.test.ts b/lib/__tests__/plugin-host-set-settings.test.ts new file mode 100644 index 000000000..7e1eee74e --- /dev/null +++ b/lib/__tests__/plugin-host-set-settings.test.ts @@ -0,0 +1,117 @@ +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 { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useLocaleStore } from '@/stores/locale-store'; +import { settingsHooks } from '../plugin-hooks'; +import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo'; + +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(id: string, perms: string[]): InstalledPlugin { + return { + id, name: id, version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} +const ASSISTANT = plugin('assistant', ['settings:read', 'settings:change']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); +type Change = { applied: Record; refused: Record; undoToken: string | null }; + +const policy = usePolicyStore.getState().policy; +function restrict(restrictions: Record, features: Record = {}) { + usePolicyStore.setState({ policy: { ...policy, restrictions: { ...policy.restrictions, ...restrictions }, features: { ...policy.features, ...features } } } as never); +} + +beforeEach(() => { + clearUndoTokens(); + useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as unknown as IJMAPClient }); + useLocaleStore.setState({ locale: 'en' }); + useSettingsStore.setState({ timeFormat: '24h', deleteAction: 'trash', showPreview: true } as never); + usePolicyStore.setState({ policy } as never); +}); +afterEach(() => { + useAuthStore.setState({ client: null }); + usePolicyStore.setState({ policy } as never); +}); + +describe('api.app.describeSettings', () => { + it('says what a change would do in the user\'s words', async () => { + const out = await call(ASSISTANT, 'app.describeSettings', { timeFormat: '12h' }) as Record>; + expect(out.timeFormat).toMatchObject({ + label: 'Time format', page: 'general', current: '24h', next: '12h', allowed: true, + }); + expect(out.timeFormat.currentLabel).toMatch(/24/); + expect(out.timeFormat.nextLabel).toMatch(/12/); + expect(out.timeFormat.options).toHaveLength(2); + }); +}); + +describe('api.app.setSettings', () => { + it('changes a setting through the store, reads it back and puts it back on undo', async () => { + const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change; + expect(result.applied).toEqual({ timeFormat: { old: '24h', new: '12h' } }); + expect(useSettingsStore.getState().timeFormat).toBe('12h'); + // The settings page counts it as the user's own choice. + expect(useSettingsStore.getState().explicitSettings).toContain('timeFormat'); + + const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[] }; + expect(undone.restored).toEqual(['timeFormat']); + expect(useSettingsStore.getState().timeFormat).toBe('24h'); + }); + + it('refuses what is not on the allow-list, values it rules out, and unknown keys', async () => { + const result = await call(ASSISTANT, 'app.setSettings', { + externalContentPolicy: 'allow', deleteAction: 'permanent', readReceiptResponse: 'always', confirmBulkDelete: false, fooBar: 1, + }) as Change; + expect(result.refused).toEqual({ + externalContentPolicy: 'not_allowed', deleteAction: 'bad_value', readReceiptResponse: 'bad_value', confirmBulkDelete: 'bad_value', fooBar: 'unknown', + }); + expect(result.undoToken).toBeNull(); + expect(useSettingsStore.getState().deleteAction).toBe('trash'); + }); + + it('refuses settings the admin locked or hid, rows a gate hides, and values outside a restriction', async () => { + restrict({ timeFormat: { locked: true }, showPreview: { hidden: true }, markAsReadDelay: { allowedValues: [0] } }, { unifiedCrossAccountEnabled: false }); + const result = await call(ASSISTANT, 'app.setSettings', { + timeFormat: '12h', showPreview: false, unifiedCrossAccount: true, markAsReadDelay: 3000, + }) as Change; + expect(result.refused).toEqual({ timeFormat: 'locked', showPreview: 'hidden', unifiedCrossAccount: 'unavailable', markAsReadDelay: 'bad_value' }); + expect(useSettingsStore.getState().timeFormat).toBe('24h'); + }); + + it('says so when another plugin stops the change', async () => { + const off = settingsHooks.onBeforeSettingChange.register('guard', (async () => false) as never); + try { + const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change; + expect(result.refused).toEqual({ timeFormat: 'stopped' }); + expect(useSettingsStore.getState().timeFormat).toBe('24h'); + } finally { + off.dispose(); + } + }, 10_000); + + it('undo writes the exact old value, but not where the admin has locked it since', async () => { + useSettingsStore.setState({ deleteAction: 'permanent' } as never); + const result = await call(ASSISTANT, 'app.setSettings', { deleteAction: 'trash', timeFormat: '12h' }) as Change; + restrict({ timeFormat: { locked: true } }); + const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[]; refused: Record }; + expect(undone).toEqual({ restored: ['deleteAction'], refused: { timeFormat: 'locked' } }); + // A value the assistant may not set, put back because the user had it. + expect(useSettingsStore.getState().deleteAction).toBe('permanent'); + }); + + it('needs settings:change, and keeps the undo to its plugin', async () => { + await expect(call(plugin('reader', ['settings:read']), 'app.setSettings', { timeFormat: '12h' })).rejects.toThrow(/lacks permission "settings:change"/); + const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change; + await expect(call(plugin('other', ['settings:change']), 'app.undoSettings', result.undoToken)).rejects.toThrow(/can't be undone/); + expect(useSettingsStore.getState().timeFormat).toBe('12h'); + }); +}); diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts index 1ac16d526..840f908c8 100644 --- a/lib/__tests__/plugin-permission-coverage.test.ts +++ b/lib/__tests__/plugin-permission-coverage.test.ts @@ -82,6 +82,17 @@ describe('plugin permissions', () => { 'drafts.create': 'email:write', 'email.forward': 'email:forward', 'notifications.show': 'ui:notifications', + // The AI assistant's actions (lib/plugin-sandbox/assistant/c.ts). + 'calendar.respond': 'calendar:respond', + 'calendar.undoDelete': 'calendar:write', + 'filters.list': 'filters:read', + 'filters.create': 'filters:write', + 'filters.undo': 'filters:write', + 'email.batch': 'email:write', + 'email.undoBatch': 'email:write', + 'app.describeSettings': 'settings:read', + 'app.setSettings': 'settings:change', + 'app.undoSettings': 'settings:change', }); }); }); diff --git a/lib/__tests__/settings-schema.test.ts b/lib/__tests__/settings-schema.test.ts new file mode 100644 index 000000000..08ee68196 --- /dev/null +++ b/lib/__tests__/settings-schema.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; +import enMessages from '@/locales/en/common.json'; +import { + COMPOSE_FONT_SIZE_IDS, + DENIED_SETTINGS, + HOVER_ACTION_IDS, + MONTH_TIMED_EVENT_STYLE_IDS, + NOTIFICATION_SOUND_IDS, + NOT_YET_SETTINGS, + OFF_LABEL, + ON_LABEL, + SETTINGS_SCHEMA, + isAllowedSettingValue, + settingValueLabelKey, + type SettingSchemaEntry, +} from '@/lib/settings-schema'; +import { ALL_HOVER_ACTIONS, MONTH_TIMED_EVENT_STYLES, useSettingsStore } from '@/stores/settings-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useLocaleStore } from '@/stores/locale-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; +import { FONT_SIZES as COMPOSER_FONT_SIZES } from '@/components/email/font-size'; +import { UNLOCKABLE_SETTING_KEYS } from '@/lib/accessibility-settings'; + +// JSON.parse keeps the last of a duplicate key, as next-intl sees it. +const messages = enMessages as unknown as Record; +const lookup = (key: string): unknown => + key.split('.').reduce((node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), messages); + +/** The settings store's own keys: its state without the actions. */ +function storeKeys(): string[] { + const state = useSettingsStore.getState() as unknown as Record; + return Object.keys(state).filter((key) => typeof state[key] !== 'function'); +} + +function currentValue(entry: SettingSchemaEntry): unknown { + switch (entry.store) { + case 'settings': return (useSettingsStore.getState() as unknown as Record)[entry.key]; + case 'theme': return useThemeStore.getState().theme; + case 'locale': return useLocaleStore.getState().locale; + case 'calendar': return useCalendarStore.getState().viewMode; + } +} + +const entries = Object.values(SETTINGS_SCHEMA); +const schema = (key: string) => { + const entry = SETTINGS_SCHEMA[key]; + if (!entry) throw new Error(`${key} is not in SETTINGS_SCHEMA`); + return entry; +}; + +describe('SETTINGS_SCHEMA', () => { + it('names keys the stores have', () => { + const keys = new Set(storeKeys()); + for (const entry of entries) { + expect(entry.key).toBeTruthy(); + if (entry.store === 'settings') expect(keys.has(entry.key), entry.key).toBe(true); + else expect(['theme', 'locale', 'viewMode']).toContain(entry.key); + } + expect(SETTINGS_SCHEMA.theme.store).toBe('theme'); + expect(SETTINGS_SCHEMA.locale.store).toBe('locale'); + expect(SETTINGS_SCHEMA.viewMode.store).toBe('calendar'); + }); + + it('uses row and value labels that exist in English', () => { + expect(typeof lookup(ON_LABEL)).toBe('string'); + expect(typeof lookup(OFF_LABEL)).toBe('string'); + for (const entry of entries) { + expect(typeof lookup(entry.label), `${entry.key}: ${entry.label}`).toBe('string'); + if (entry.kind.type === 'choice' || entry.kind.type === 'list') { + for (const option of entry.kind.options) { + if (option.label === null) continue; + expect(typeof lookup(option.label), `${entry.key}=${String(option.value)}: ${option.label}`).toBe('string'); + } + } + } + expect(typeof lookup(settingValueLabelKey(schema('timeZone'), 'auto')!)).toBe('string'); + }); + + it('allows every default value, except an off safety net that may only be turned on', () => { + const refused: string[] = []; + for (const entry of entries) { + const value = currentValue(entry); + if (!isAllowedSettingValue(entry, value)) refused.push(`${entry.key}=${JSON.stringify(value)}`); + } + // Confirm delete is off by default; a plugin may turn it on, not off. + expect(refused).toEqual(['confirmDelete=false']); + }); + + it('marks presets, third-party rows and the inverted switch', () => { + for (const entry of entries) { + expect(!!entry.preset, entry.key).toBe(entry.store === 'settings' && UNLOCKABLE_SETTING_KEYS.has(entry.key)); + } + expect(schema('senderFavicons').thirdParty).toBe(true); + expect(schema('showAvatarsInJunk').thirdParty).toBe(true); + expect(schema('disableThreading').inverted).toBe(true); + expect(schema('unifiedCrossAccount').gate).toBe('unifiedCrossAccountEnabled'); + expect(schema('enableCrossUnreadView').gate).toBe('crossUnreadViewEnabled'); + expect(schema('archiveMode').gate).toBe('archiveActionEnabled'); + expect(schema('hoverActions').gate).toBe('hoverActionsConfigEnabled'); + expect(schema('enableCalendarTasks').gate).toBe('calendarTasksEnabled'); + }); + + it('keeps its copies of app lists in step with the app', () => { + expect(HOVER_ACTION_IDS.map(([id, labelKey]) => ({ id, labelKey }))).toEqual(ALL_HOVER_ACTIONS); + expect([...MONTH_TIMED_EVENT_STYLE_IDS]).toEqual([...MONTH_TIMED_EVENT_STYLES]); + expect([...NOTIFICATION_SOUND_IDS]).toEqual(NOTIFICATION_SOUNDS.map((s) => s.id)); + expect([...COMPOSE_FONT_SIZE_IDS]).toEqual([...COMPOSER_FONT_SIZES]); + }); +}); + +describe('DENIED_SETTINGS and NOT_YET_SETTINGS', () => { + it('refuses the privacy, mail-sending and dead settings', () => { + for (const key of [ + 'externalContentPolicy', 'trustedSenders', 'signatureDetails', 'pushRelayUrl', 'defaultReplyMode', + 'readAloudVoice', 'sessionTimeout', 'debugMode', 'settingsSyncDisabled', + ]) { + expect(SETTINGS_SCHEMA[key], key).toBeUndefined(); + expect(DENIED_SETTINGS, key).toContain(key); + } + }); + + it('decides every key of the settings store exactly once', () => { + const allowed = Object.keys(SETTINGS_SCHEMA); + const all = [...allowed, ...DENIED_SETTINGS, ...NOT_YET_SETTINGS]; + expect(new Set(all).size, 'a key is in two lists').toBe(all.length); + const undecided = storeKeys().filter((key) => !all.includes(key)); + expect(undecided).toEqual([]); + // And no list names a key the app doesn't have. + const known = new Set([...storeKeys(), 'theme', 'locale', 'viewMode', 'activeThemeId']); + expect(all.filter((key) => !known.has(key))).toEqual([]); + }); +}); + +describe('isAllowedSettingValue', () => { + const refuse = (key: string, value: unknown) => + expect(isAllowedSettingValue(schema(key), value), `${key}=${JSON.stringify(value)}`).toBe(false); + const allow = (key: string, value: unknown) => + expect(isAllowedSettingValue(schema(key), value), `${key}=${JSON.stringify(value)}`).toBe(true); + + it('refuses the values the plan rules out', () => { + refuse('deleteAction', 'permanent'); + refuse('readReceiptResponse', 'always'); + refuse('confirmDelete', false); + refuse('confirmBulkDelete', false); + refuse('permanentlyDeleteJunk', true); + refuse('postExportAction', 'trash'); + refuse('timeZone', 'Mars/Base'); + refuse('composeFontFamily', 'Comic Sans MS; background: url(https://evil.example/x)'); + refuse('composeFontSize', '13px'); + }); + + it('allows the values the pages offer', () => { + allow('timeFormat', '12h'); + allow('timeZone', 'Europe/Berlin'); + allow('timeZone', 'auto'); + allow('deleteAction', 'trash-and-read'); + allow('confirmDelete', true); + allow('confirmBulkDelete', true); + allow('permanentlyDeleteJunk', false); + allow('postExportAction', 'archive'); + allow('composeFontFamily', 'serif'); + allow('composeFontSize', '18px'); + allow('readReceiptResponse', 'never'); + }); + + it('checks types and ranges', () => { + refuse('timeFormat', '13h'); + refuse('showPreview', 'true'); + refuse('emailsPerPage', '50'); + allow('emailsPerPage', 100); + refuse('emailsPerPage', 1000); + refuse('markAsReadDelay', 1000); + allow('markAsReadDelay', -1); + allow('calendarDayStartHour', 0); + refuse('calendarDayStartHour', 24); + refuse('calendarDayStartHour', 7.5); + allow('calendarDayEndHour', 24); + refuse('calendarDayEndHour', 0); + refuse('timeZone', '+01:00'); + refuse('timeZone', 'europe/berlin'); + refuse('timeZone', 42); + allow('locale', 'de'); + allow('locale', 'zh-TW'); + allow('locale', ''); + refuse('locale', 'xx'); + allow('theme', 'dark'); + refuse('theme', 'neon'); + allow('viewMode', 'agenda'); + refuse('viewMode', 'tasks'); + }); + + it('checks lists', () => { + allow('calendarWorkingDays', [1, 2, 3]); + refuse('calendarWorkingDays', []); + refuse('calendarWorkingDays', [1, 1]); + refuse('calendarWorkingDays', [7]); + allow('hoverActions', ['star', 'spam']); + allow('hoverActions', []); + refuse('hoverActions', ['star', 'star']); + refuse('hoverActions', ['forward']); + allow('attachmentReminderKeywords', ['attached', 'anbei']); + refuse('attachmentReminderKeywords', ['Attached']); + refuse('attachmentReminderKeywords', [' attached']); + refuse('attachmentReminderKeywords', ['a\nb']); + refuse('attachmentReminderKeywords', ['x'.repeat(41)]); + refuse('attachmentReminderKeywords', Array.from({ length: 51 }, (_, i) => `w${i}`)); + refuse('attachmentReminderKeywords', 'attached'); + }); +}); + +describe('settingValueLabelKey', () => { + it('gives the page labels, turning the inverted switch round', () => { + expect(settingValueLabelKey(schema('showPreview'), true)).toBe(ON_LABEL); + expect(settingValueLabelKey(schema('showPreview'), false)).toBe(OFF_LABEL); + // disableThreading true = "Group conversations" off. + expect(settingValueLabelKey(schema('disableThreading'), true)).toBe(OFF_LABEL); + expect(settingValueLabelKey(schema('disableThreading'), false)).toBe(ON_LABEL); + expect(settingValueLabelKey(schema('timeFormat'), '12h')).toBe('settings.language_region.time_format.12h'); + expect(settingValueLabelKey(schema('markAsReadDelay'), -1)).toBe('settings.email_behavior.mark_read.never'); + expect(settingValueLabelKey(schema('firstDayOfWeek'), 0)).toBe('calendar.days.sunday'); + }); + + it('gives null for formatted values', () => { + expect(settingValueLabelKey(schema('calendarDayStartHour'), 8)).toBeNull(); + expect(settingValueLabelKey(schema('readAloudRate'), 1.25)).toBeNull(); + expect(settingValueLabelKey(schema('toastDuration'), 10)).toBeNull(); + expect(settingValueLabelKey(schema('toastDuration'), 0)).toBe('settings.accessibility.toast_duration.until_closed'); + expect(settingValueLabelKey(schema('timeZone'), 'Europe/Berlin')).toBeNull(); + }); +}); diff --git a/lib/plugin-sandbox/assistant/c.batch.ts b/lib/plugin-sandbox/assistant/c.batch.ts new file mode 100644 index 000000000..5ed3b0c8c --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.batch.ts @@ -0,0 +1,150 @@ +// Area c, batches: email.batch / email.undoBatch. One change to up to 200 +// messages of the signed-in account, through the email store's own batch +// functions (so their plugin hooks run and the list updates), with restore +// points taken first and one entry in the app's undo history: the plugin's +// Undo and Ctrl+Z take back the same thing, once. + +import type { Email } from '../../jmap/types'; +import type { IJMAPClient } from '../../jmap/client-interface'; +import { useEmailStore, type EmailRestoreBatch, type BatchActionTarget } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { isArchiveActionEnabled } from '../../archive-gate'; +import { recordUndo, runUndo } from '../../undo-history'; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from '../../thread-utils'; +import { readId, readObject, readString, sessionClient, withinRate } from '../host-api-input'; +import { MAX_PLUGIN_MOVES_PER_MINUTE } from '../host-api-mail'; +import { issueUndoToken, redeemUndoToken } from './c.undo'; +import type { MailBatchAction, MailBatchResult } from './c.types'; + +/** Most messages one email.batch call changes. */ +export const MAX_PLUGIN_BATCH = 200; +const ACTIONS: readonly MailBatchAction[] = ['archive', 'move', 'read', 'unread', 'tag', 'untag']; +const FIELDS = ['id', 'mailboxIds', 'keywords', 'receivedAt', 'threadId']; + +interface BatchUndo { + entryId: string; + /** Runs the undo once; the history entry and the plugin's call share it. */ + run: () => Promise; +} + +function once(fn: () => Promise): () => Promise { + let promise: Promise | null = null; + return () => (promise ??= fn()); +} + +function readIds(value: unknown, method: string): string[] { + if (!Array.isArray(value) || value.length === 0) throw new Error(`${method}: emailIds must be a non-empty array`); + if (value.length > MAX_PLUGIN_BATCH) throw new Error(`${method}: at most ${MAX_PLUGIN_BATCH} messages at once`); + return [...new Set(value.map((id, i) => readId(id, method, `emailIds[${i}]`)))]; +} + +const hasTag = (email: Email, tagId: string) => !!(email.keywords?.[KEYWORD_PREFIX + tagId] || email.keywords?.[KEYWORD_PREFIX_LEGACY + tagId]); + +function withTag(email: Email, tagId: string, on: boolean): Email { + const keywords = { ...email.keywords }; + if (on) keywords[KEYWORD_PREFIX + tagId] = true; + else { delete keywords[KEYWORD_PREFIX + tagId]; delete keywords[KEYWORD_PREFIX_LEGACY + tagId]; } + return { ...email, keywords }; +} + +/** + * Change many messages of the signed-in account at once: archive (by the + * user's archiveMode), move into one of its own folders (not a shared one, + * not the trash), mark read or unread, put a tag on or take it off. + */ +export async function doEmailBatch(pluginId: string, input: unknown): Promise { + const method = 'email.batch'; + const data = readObject(input, method, 'batch', ['emailIds', 'action', 'mailboxId', 'tagId']); + const ids = readIds(data.emailIds, method); + const action = data.action as MailBatchAction; + if (!ACTIONS.includes(action)) throw new Error(`${method}: action must be one of ${ACTIONS.join(', ')}`); + if (action !== 'move' && data.mailboxId !== undefined) throw new Error(`${method}: mailboxId is only for move`); + if (action !== 'tag' && action !== 'untag' && data.tagId !== undefined) throw new Error(`${method}: tagId is only for tag and untag`); + const client = sessionClient(method); + const store = useEmailStore.getState(); + + let mailboxId = ''; + if (action === 'move') { + mailboxId = readId(data.mailboxId, method, 'mailboxId'); + // Only the account's own folders, never one shared with other people. + const box = store.mailboxes.find((m) => m.id === mailboxId && !m.isShared); + if (!box || (box.shareWith && Object.keys(box.shareWith).length > 0)) { + throw new Error(`${method}: mailboxId is not a folder of this account that only the user reads`); + } + if (box.role === 'trash') throw new Error(`${method}: messages can't be moved to the trash this way`); + } + let tagId = ''; + if (action === 'tag' || action === 'untag') { + tagId = readString(data.tagId, method, 'tagId', 100, { min: 1, trim: true }); + if (!useSettingsStore.getState().emailKeywords.some((k) => k.id === tagId)) throw new Error(`${method}: tagId is not one of the user's tags`); + } + if (action === 'archive' && !isArchiveActionEnabled()) throw new Error(`${method}: archiving is switched off by the administrator`); + if ((action === 'archive' || action === 'move') && !withinRate('email.move', pluginId, MAX_PLUGIN_MOVES_PER_MINUTE)) { + throw new Error(`${method}: too many moves, try again in a minute`); + } + + // The messages as the server has them now, from the signed-in account only. + const emails = (await client.getEmailFields(ids, FIELDS, undefined)) as unknown as Email[]; + const found = emails.filter((e) => e && typeof e.id === 'string' && ids.includes(e.id)); + const missing = ids.length - found.length; + const target: BatchActionTarget = { emailIds: found.map((e) => e.id), client, emails: found }; + const none: MailBatchResult = { done: 0, failed: ids.length, undoToken: null }; + if (found.length === 0) return none; + + let done = 0; + let undo: () => Promise; + let label: string; + if (action === 'archive' || action === 'move') { + const batches: EmailRestoreBatch[] = [{ + client, accountId: undefined, + points: found.map((e) => ({ id: e.id, mailboxIds: { ...e.mailboxIds }, seen: !!e.keywords?.$seen })), + }]; + const ok = action === 'archive' + ? await store.batchArchive(client, target) + : await store.batchMoveToMailbox(client, mailboxId, target); + if (!ok) return none; + done = found.length; + undo = () => useEmailStore.getState().undoMove(client, batches, []); + label = `${action} ${done}`; + } else if (action === 'read' || action === 'unread') { + const read = action === 'read'; + const changed = found.filter((e) => !!e.keywords?.$seen !== read).map((e) => e.id); + if (changed.length > 0 && !(await store.batchMarkAsRead(client, read, { emailIds: changed, client }))) return none; + done = found.length; + undo = async () => { + if (changed.length > 0 && !(await useEmailStore.getState().batchMarkAsRead(client, !read, { emailIds: changed, client }))) { + throw new Error('stopped'); + } + }; + label = `${action} ${done}`; + } else { + const add = action === 'tag'; + const changed = found.filter((e) => hasTag(e, tagId) !== add); + if (changed.length > 0 && !(await store.batchSetTag(client, tagId, add, target))) return none; + done = found.length; + const after = changed.map((e) => withTag(e, tagId, add)); + undo = async () => { + if (after.length > 0 && !(await useEmailStore.getState().batchSetTag(client, tagId, !add, { emailIds: after.map((e) => e.id), client, emails: after }))) { + throw new Error('stopped'); + } + }; + label = `${action} ${done}`; + } + + const run = once(undo); + // One entry in the app's undo history: Ctrl+Z reaches it too. + const entry = recordUndo(`Plugin ${pluginId}: ${label} messages`, () => { void run().catch(() => { /* the plugin's undo reports it */ }); }); + const token = issueUndoToken(pluginId, client.getAccountId(), 'batch', { entryId: entry.id, run } satisfies BatchUndo); + return { done, failed: missing, undoToken: token }; +} + +/** Take back an email.batch: the messages go back to their folders, read state and tags. */ +export async function doEmailUndoBatch(pluginId: string, token: unknown): Promise { + const method = 'email.undoBatch'; + const client: IJMAPClient = sessionClient(method); + const { entryId, run } = redeemUndoToken(method, pluginId, client.getAccountId(), 'batch', token); + // Off the history first, so Ctrl+Z can't run it again; an entry already + // taken by Ctrl+Z has run (or is running) the same undo. + runUndo(entryId); + await run(); +} diff --git a/lib/plugin-sandbox/assistant/c.calendar.ts b/lib/plugin-sandbox/assistant/c.calendar.ts new file mode 100644 index 000000000..20d07c5e4 --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.calendar.ts @@ -0,0 +1,170 @@ +// Area c, calendar: answering an invitation (calendar.respond) and deleting +// an event so it can be taken back (calendar.deleteEvent with an undo token, +// calendar.undoDelete). + +import type { CalendarEvent, Calendar } from '../../jmap/types'; +import type { IJMAPClient } from '../../jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { findParticipantByEmails, getInvitationTrustAssessment } from '../../calendar-invitation'; +import { answerInvitation, attendeeOf, readOwnInvitation, type RsvpStatus } from '../../calendar-rsvp'; +import { readId, sessionClient } from '../host-api-input'; +import { readNamedEmail } from '../host-api-mail'; +import { doCalendarDeleteEvent } from '../host-api-calendar'; +import { issueUndoToken, redeemUndoToken } from './c.undo'; +import type { EventDeleteResult, InvitationResponseView } from './c.types'; + +const STATUSES: readonly RsvpStatus[] = ['accepted', 'tentative', 'declined']; + +/** Every address the user sends as, primary first, lowercased. */ +function identityEmails(): string[] { + const { primaryIdentity, identities } = useAuthStore.getState(); + const emails = [primaryIdentity?.email, ...identities.map((i) => i.email)] + .filter((address): address is string => Boolean(address)) + .map((address) => address.toLowerCase()); + return [...new Set(emails)]; +} + +function ownCalendars(): Calendar[] { + return useCalendarStore.getState().calendars.filter((c) => !c.isShared && !c.isTasksOnly); +} + +/** The copy of the invitation in the user's own calendar, with its participants loaded. */ +async function ownCopy(client: IJMAPClient, uid: string | undefined): Promise { + if (!uid) return null; + const known = useCalendarStore.getState().events.find((e) => e.uid === uid && !e.isShared); + if (known) return known; + const found = (await client.queryCalendarEvents({ uid })).find((e) => !e.recurrenceId) ?? null; + if (found) { + useCalendarStore.setState((s) => (s.events.some((e) => e.id === found.id) ? s : { events: [...s.events, found] })); + } + return found; +} + +/** + * Answer the invitation a message of the user's own account carries, the + * way the reader's banner answers it: the calendar store writes the answer + * and the server sends the reply to the organiser. Resolves to what was + * answered, or null when another plugin stopped it. Refuses a message that + * is no invitation (a reply, a cancellation), an event the user organises, + * one whose sender doesn't check out, and works only while invitations are + * read (the setting) and the account has a calendar. + */ +export async function doCalendarRespond(emailIdArg: unknown, statusArg: unknown): Promise { + const method = 'calendar.respond'; + const emailId = readId(emailIdArg, method, 'emailId'); + if (!STATUSES.includes(statusArg as RsvpStatus)) throw new Error(`${method}: status must be one of ${STATUSES.join(', ')}`); + const status = statusArg as RsvpStatus; + const client = sessionClient(method); + if (!useSettingsStore.getState().calendarInvitationParsingEnabled) { + throw new Error(`${method}: reading invitations is switched off in Settings > Calendar`); + } + const calendarState = useCalendarStore.getState(); + if (!calendarState.supportsCalendar) throw new Error(`${method}: this account has no calendar`); + + const named = await readNamedEmail(client, emailId, method); + if (!named) throw new Error(`${method}: no message with id "${emailId}"`); + // Mail of a shared or group mailbox is answered as that account: only in the reader. + if (named.accountId !== undefined) throw new Error(`${method}: answer invitations of other accounts in the reader`); + const { email } = named; + + const invitation = await readOwnInvitation(client, email); + if (!invitation) throw new Error(`${method}: the message has no invitation`); + const { parsedEvent, method: itip } = invitation; + if (itip !== 'request') throw new Error(`${method}: the message is not an invitation that asks for an answer`); + + const emails = identityEmails(); + const sender = email.from?.[0]?.email?.toLowerCase(); + if (!(sender && emails.includes(sender))) { + const trust = getInvitationTrustAssessment(parsedEvent, email, itip, emails); + if (trust?.level === 'warning') throw new Error(`${method}: the invitation may not come from who it names; answer it in the reader`); + } + + const existing = await ownCopy(client, parsedEvent.uid); + const { participant: myParticipant, isOrganizer } = attendeeOf( + (existing ? findParticipantByEmails(existing, emails) : null) ?? findParticipantByEmails(parsedEvent, emails), + ); + if (isOrganizer) throw new Error(`${method}: the user organises this event`); + const calendars = ownCalendars(); + const calendarId = (calendars.find((c) => c.isDefault) ?? calendars[0])?.id; + + const store = useCalendarStore.getState(); + const outcome = await answerInvitation({ + client, + store: { rsvpEvent: store.rsvpEvent, updateEvent: store.updateEvent, importEvents: store.importEvents, events: () => useCalendarStore.getState().events }, + parsedEvent, + existingEvent: existing, + myParticipant, + actingEmails: emails, + calendarId, + sharedMailAccount: null, + resolveExisting: async () => { + if (!existing) return null; + if (existing.participants && Object.keys(existing.participants).length > 0) return existing; + const hydrated = await client.getCalendarEvent(existing.id); + if (!hydrated) return existing; + useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === hydrated.id ? hydrated : e)) })); + return hydrated; + }, + }, status); + if (outcome === 'stopped') return null; + if (outcome === 'added') throw new Error(`${method}: the event was added to the calendar, but the user is not among its participants, so no answer was sent`); + if (outcome === 'failed') throw new Error(`${method}: the invitation could not be answered`); + const organizer = parsedEvent.organizerCalendarAddress ?? parsedEvent.replyTo?.imip ?? null; + return { + status, + title: parsedEvent.title ?? '', + start: parsedEvent.start ?? null, + organizer: organizer ? organizer.replace(/^mailto:/i, '') : null, + }; +} + +// ─── Delete with undo ───────────────────────────────────────── + +/** What the server sets itself, and the app's own fields: left out when the event is created again. */ +const NOT_RECREATED = [ + 'id', 'created', 'updated', 'sequence', 'isDraft', 'isOrigin', 'baseEventId', 'blobId', 'utcStart', 'utcEnd', + 'originalId', 'originalCalendarIds', 'accountId', 'accountName', 'isShared', +] as const; + +/** A single event of the user's own calendars: the only kind an undo can put back as it was. */ +function restorable(event: CalendarEvent | null, id: string): event is CalendarEvent { + return !!event && event.id === id && !event.isShared && !event.recurrenceId + && !(event.recurrenceRules && event.recurrenceRules.length > 0) + && !(event.recurrenceOverrides && Object.keys(event.recurrenceOverrides).length > 0); +} + +/** + * calendar.deleteEvent as before (one occurrence when given an occurrence's + * id; never sends cancellations), and now with an undo token for a single + * event of the user's own calendars. The host keeps the copy; undoDelete + * creates it again with the same uid. + */ +export async function doCalendarDeleteWithUndo(pluginId: string, idArg: unknown): Promise { + const method = 'calendar.deleteEvent'; + const id = readId(idArg, method); + const client = sessionClient(method); + const event = await client.getCalendarEvent(id).catch(() => null); + if (!restorable(event, id)) { + await doCalendarDeleteEvent(id); + return { undoToken: null }; + } + if (!useCalendarStore.getState().events.some((e) => e.id === id)) { + useCalendarStore.setState((s) => ({ events: [...s.events, event] })); + } + const deleted = await useCalendarStore.getState().deleteEvent(client, id, false); + if (!deleted) return { undoToken: null }; + const copy: Record = { ...event }; + for (const key of NOT_RECREATED) delete copy[key]; + return { undoToken: issueUndoToken(pluginId, client.getAccountId(), 'event', copy) }; +} + +/** Puts back an event calendar.deleteEvent deleted, from the host's copy. Sends no invitations. */ +export async function doCalendarUndoDelete(pluginId: string, tokenArg: unknown): Promise<{ id: string } | null> { + const method = 'calendar.undoDelete'; + const client = sessionClient(method); + const copy = redeemUndoToken>(method, pluginId, client.getAccountId(), 'event', tokenArg); + const created = await useCalendarStore.getState().createEvent(client, copy, false); + return created ? { id: created.id } : null; +} diff --git a/lib/plugin-sandbox/assistant/c.filters.ts b/lib/plugin-sandbox/assistant/c.filters.ts new file mode 100644 index 000000000..a3f938c68 --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.filters.ts @@ -0,0 +1,232 @@ +// Area c, filters: filters.list / create / undo over the model Settings > +// Filters uses (lib/filters/account-filters.ts). A rule is checked the way +// the rule editor checks it, and nothing that sends mail elsewhere or drops +// it may be in it: no forward or redirect, no reject (it answers the +// sender), no discard (it deletes), no keep. Moves and copies go only into +// folders of the filters' own account that nobody else shares. + +import type { FilterAction, FilterCondition, FilterRule } from '../../jmap/sieve-types'; +import type { IJMAPClient } from '../../jmap/client-interface'; +import type { Mailbox } from '../../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { + OpaqueFiltersError, readAccountFilters, restoreAccountFilters, updateAccountFilters, FiltersChangedError, + FiltersSaveVetoedError, type FiltersChange, +} from '../../filters/account-filters'; +import { insertRuleAtTop, mailboxSievePath } from '../../filters/quick-rules'; +import { isValidSieveHeaderName } from '../../sieve/generator'; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from '../../thread-utils'; +import { generateUUID } from '../../utils'; +import { isPlainRecord, readId, readObject, readString, sessionClient } from '../host-api-input'; +import { issueUndoToken, redeemUndoToken } from './c.undo'; +import type { + FilterCreateResult, FilterRuleActionType, FilterRuleField, FilterRuleInfo, FilterRuleOp, FiltersListView, +} from './c.types'; + +const MAX_CONDITIONS = 10; +const MAX_ACTIONS = 10; +const MAX_VALUES = 20; +const MAX_VALUE = 500; + +const ADDRESS_OPS: FilterRuleOp[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'address_is', 'domain_is']; +const TEXT_OPS: FilterRuleOp[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with']; +/** Which comparisons each field takes. */ +export const FILTER_FIELD_OPS: Readonly> = { + from: ADDRESS_OPS, to: ADDRESS_OPS, cc: ADDRESS_OPS, + subject: TEXT_OPS, body: ['contains', 'not_contains'], header: TEXT_OPS, + size: ['greater_than', 'less_than'], + attachment: ['has_any', 'has_type'], +}; +/** The only actions a plugin's rule may have. */ +export const FILTER_ACTIONS: readonly FilterRuleActionType[] = ['move', 'copy', 'mark_read', 'star', 'add_label', 'stop']; + +function sieveClient(method: string): IJMAPClient { + const client = sessionClient(method); + if (!client.supportsSieve()) throw new Error(`${method}: the mail server has no filters`); + return client; +} + +/** The folders of the filters' account, as the server has them. */ +async function sieveMailboxes(client: IJMAPClient, accountId: string): Promise { + return client.getMailboxes(accountId === client.getAccountId() ? undefined : accountId); +} + +const tagIdOf = (keyword: string | undefined) => (keyword?.startsWith(KEYWORD_PREFIX) + ? keyword.slice(KEYWORD_PREFIX.length) + : keyword?.startsWith(KEYWORD_PREFIX_LEGACY) ? keyword.slice(KEYWORD_PREFIX_LEGACY.length) : keyword); + +function ruleToInfo(rule: FilterRule, mailboxes: Mailbox[]): FilterRuleInfo { + return { + id: rule.id, + name: rule.name, + isActive: rule.enabled, + editable: !rule.origin || rule.origin === 'bulwark', + match: rule.matchType, + conditions: rule.conditions.map((c) => ({ + field: c.field, op: c.comparator, value: Array.isArray(c.value) ? [...c.value] : c.value, + ...(c.headerName ? { headerName: c.headerName } : {}), + })), + actions: rule.actions.map((a) => { + if (a.type === 'move' || a.type === 'copy') { + const box = a.mailboxId ? mailboxes.find((m) => m.id === a.mailboxId) : undefined; + return { type: a.type, ...(a.mailboxId ? { mailboxId: a.mailboxId } : {}), folder: box ? mailboxSievePath(mailboxes, box.id) : (a.value ?? '') }; + } + if (a.type === 'add_label') return { type: a.type, tagId: tagIdOf(a.value) ?? '' }; + if (a.type === 'forward') return { type: a.type, value: a.value ?? '' }; + return { type: a.type }; + }), + }; +} + +/** The account's filter rules, and whether rules can be added. */ +export async function doFiltersList(): Promise { + const method = 'filters.list'; + const client = sessionClient(method); + if (!client.supportsSieve()) return { supported: false, editable: false, rules: [] }; + const accountId = client.getSieveAccountId(); + const [filters, mailboxes] = await Promise.all([readAccountFilters(client, accountId), sieveMailboxes(client, accountId)]); + if (filters.parsed.isOpaque) return { supported: true, editable: false, rules: [] }; + return { supported: true, editable: true, rules: filters.parsed.rules.map((r) => ruleToInfo(r, mailboxes)) }; +} + +function readValues(value: unknown, method: string, field: string): string[] { + const list = Array.isArray(value) ? value : [value]; + if (list.length === 0 || list.length > MAX_VALUES) throw new Error(`${method}: ${field} takes 1 to ${MAX_VALUES} values`); + return list.map((v, i) => { + const text = readString(v, method, `${field}[${i}]`, MAX_VALUE, { min: 1, trim: true }); + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(text)) throw new Error(`${method}: ${field} has a line break or a control character`); + return text; + }); +} + +function readCondition(entry: unknown, method: string, i: number): FilterCondition { + const at = `conditions[${i}]`; + const c = readObject(entry, method, at, ['field', 'op', 'value', 'headerName']); + const field = c.field as FilterRuleField; + const ops = FILTER_FIELD_OPS[field]; + if (!ops) throw new Error(`${method}: ${at}.field must be one of ${Object.keys(FILTER_FIELD_OPS).join(', ')}`); + const op = c.op as FilterRuleOp; + if (!ops.includes(op)) throw new Error(`${method}: ${at}.op for ${field} must be one of ${ops.join(', ')}`); + const condition: FilterCondition = { field, comparator: op, value: '' }; + if (op === 'has_any') { + if (c.value !== undefined && c.value !== '') throw new Error(`${method}: ${at} takes no value`); + } else if (field === 'size') { + const size = typeof c.value === 'number' ? String(c.value) : c.value; + if (typeof size !== 'string' || !/^\d{1,10}$/.test(size)) throw new Error(`${method}: ${at}.value must be a size in bytes`); + condition.value = size; + } else { + const values = readValues(c.value, method, `${at}.value`); + condition.value = values.length === 1 ? values[0] : values; + } + if (field === 'header') { + const name = readString(c.headerName, method, `${at}.headerName`, 100, { min: 1, trim: true }); + if (!isValidSieveHeaderName(name)) throw new Error(`${method}: ${at}.headerName is not a header name`); + condition.headerName = name; + } else if (c.headerName !== undefined) { + throw new Error(`${method}: ${at}.headerName is only for field header`); + } + return condition; +} + +function readAction(entry: unknown, method: string, i: number, mailboxes: Mailbox[]): FilterAction { + const at = `actions[${i}]`; + if (!isPlainRecord(entry)) throw new Error(`${method}: ${at} must be an object`); + const type = entry.type; + if (!FILTER_ACTIONS.includes(type as FilterRuleActionType)) { + // Forward, redirect, reject, discard and keep are refused by name, so a + // plugin (or a model behind it) hears that they are not allowed at all. + throw new Error(`${method}: ${at}.type "${String(type)}" is not allowed; a rule may only ${FILTER_ACTIONS.join(', ')}`); + } + const a = readObject(entry, method, at, ['type', 'mailboxId', 'tagId']); + if (type === 'move' || type === 'copy') { + const id = readId(a.mailboxId, method, `${at}.mailboxId`); + const box = mailboxes.find((m) => m.id === id); + // Only the account's own folders, and none that is shared with others: + // a rule must not file mail where other people read it. + if (!box || box.isShared || (box.shareWith && Object.keys(box.shareWith).length > 0)) { + throw new Error(`${method}: ${at}.mailboxId is not a folder of this account that only the user reads`); + } + if (box.role === 'trash') throw new Error(`${method}: ${at} may not move mail to the trash`); + if (a.tagId !== undefined) throw new Error(`${method}: ${at}.tagId is only for add_label`); + return { type, mailboxId: id, value: mailboxSievePath(mailboxes, id) }; + } + if (a.mailboxId !== undefined) throw new Error(`${method}: ${at}.mailboxId is only for move and copy`); + if (type === 'add_label') { + const tagId = readString(a.tagId, method, `${at}.tagId`, 100, { min: 1, trim: true }); + if (!useSettingsStore.getState().emailKeywords.some((k) => k.id === tagId)) throw new Error(`${method}: ${at}.tagId is not one of the user's tags`); + return { type, value: tagId }; + } + if (a.tagId !== undefined) throw new Error(`${method}: ${at}.tagId is only for add_label`); + return { type: type as FilterAction['type'] }; +} + +/** The rule a plugin asked for, checked like the rule editor checks one. */ +export function readFilterRule(input: unknown, method: string, mailboxes: Mailbox[]): FilterRule { + const data = readObject(input, method, 'rule', ['name', 'match', 'conditions', 'actions']); + const name = readString(data.name, method, 'name', 100, { min: 1, trim: true }); + const match = data.match === undefined ? 'all' : data.match; + if (match !== 'all' && match !== 'any') throw new Error(`${method}: match must be all or any`); + if (!Array.isArray(data.conditions) || data.conditions.length === 0 || data.conditions.length > MAX_CONDITIONS) { + throw new Error(`${method}: conditions must hold 1 to ${MAX_CONDITIONS} conditions`); + } + if (!Array.isArray(data.actions) || data.actions.length === 0 || data.actions.length > MAX_ACTIONS) { + throw new Error(`${method}: actions must hold 1 to ${MAX_ACTIONS} actions`); + } + const conditions = data.conditions.map((c, i) => readCondition(c, method, i)); + const actions = data.actions.map((a, i) => readAction(a, method, i, mailboxes)); + if (!actions.some((a) => a.type !== 'stop')) throw new Error(`${method}: the rule must do something besides stop`); + const stop = actions.some((a) => a.type === 'stop'); + return { + id: generateUUID(), + name, + enabled: true, + matchType: match, + conditions, + actions: actions.filter((a) => a.type !== 'stop'), + stopProcessing: stop, + }; +} + +/** Let an open Settings > Filters page show the change. */ +function refreshFilterStore(client: IJMAPClient, accountId: string): void { + const filters = useFilterStore.getState(); + if (useAuthStore.getState().client === client && filters.selectedAccountId === accountId) { + void filters.fetchFilters(client, accountId); + } +} + +/** Add a rule at the top of the account's filters. Resolves to its id and an undo token. */ +export async function doFiltersCreate(pluginId: string, input: unknown): Promise { + const method = 'filters.create'; + const client = sieveClient(method); + const accountId = client.getSieveAccountId(); + const rule = readFilterRule(input, method, await sieveMailboxes(client, accountId)); + let change: FiltersChange | null; + try { + change = await updateAccountFilters(client, accountId, (rules) => insertRuleAtTop(rules, rule)); + } catch (err) { + if (err instanceof OpaqueFiltersError) throw new Error(`${method}: the filters were edited by hand; add the rule in Settings > Filters`); + if (err instanceof FiltersSaveVetoedError) throw new Error(`${method}: another plugin stopped the change`); + throw err; + } + if (!change) throw new Error(`${method}: nothing was written`); + refreshFilterStore(client, accountId); + return { id: rule.id, undoToken: issueUndoToken(pluginId, client.getAccountId(), 'filters', change) }; +} + +/** Take back a rule filters.create added: the script gets its earlier bytes back. */ +export async function doFiltersUndo(pluginId: string, token: unknown): Promise { + const method = 'filters.undo'; + const client = sieveClient(method); + const change = redeemUndoToken(method, pluginId, client.getAccountId(), 'filters', token); + try { + await restoreAccountFilters(client, change); + } catch (err) { + if (err instanceof FiltersChangedError) throw new Error(`${method}: the filters changed since; change them in Settings > Filters`); + throw err; + } + refreshFilterStore(client, change.accountId); +} diff --git a/lib/plugin-sandbox/assistant/c.runtime.ts b/lib/plugin-sandbox/assistant/c.runtime.ts index 5e63b9136..8e1aa2ba1 100644 --- a/lib/plugin-sandbox/assistant/c.runtime.ts +++ b/lib/plugin-sandbox/assistant/c.runtime.ts @@ -1,8 +1,39 @@ // Area c of the assistant's host APIs, inside the plugin sandbox (see README.md). import type { CallApi, RuntimeAdditions } from './runtime'; +import type { + FilterRuleInput, InvitationAnswer, MailBatchInput, SettingValue, +} from './c.types'; /** The methods this area adds to the plugin's api, by namespace. */ -export function runtime(_callApi: CallApi): RuntimeAdditions { - return {}; +export function runtime(callApi: CallApi): RuntimeAdditions { + return { + calendar: { + // Answer an invitation in a message of the user's own account, as the + // reader's banner does; the server sends the reply to the organiser + // (permission calendar:respond). + respond: (emailId: string, status: InvitationAnswer) => callApi('calendar.respond', [emailId, status]), + // Put back an event calendar.deleteEvent deleted, from its undo token (calendar:write). + undoDelete: (undoToken: string) => callApi('calendar.undoDelete', [undoToken]), + }, + filters: { + // The account's filter rules (filters:read). + list: () => callApi('filters.list', []), + // Add a rule at the top; no forwarding, rejecting or deleting rules (filters:write). + create: (rule: FilterRuleInput) => callApi('filters.create', [rule]), + undo: (undoToken: string) => callApi('filters.undo', [undoToken]), + }, + email: { + // One change to up to 200 messages of the account, with one Undo (email:write). + batch: (batch: MailBatchInput) => callApi('email.batch', [batch]), + undoBatch: (undoToken: string) => callApi('email.undoBatch', [undoToken]), + }, + app: { + // Settings in the user's language: label, page, current and new value (settings:read). + describeSettings: (changes: Record | string[]) => callApi('app.describeSettings', [changes]), + // Change settings on the allow-list (settings:change). + setSettings: (changes: Record) => callApi('app.setSettings', [changes]), + undoSettings: (undoToken: string) => callApi('app.undoSettings', [undoToken]), + }, + }; } diff --git a/lib/plugin-sandbox/assistant/c.settings.ts b/lib/plugin-sandbox/assistant/c.settings.ts new file mode 100644 index 000000000..32b65c8a9 --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.settings.ts @@ -0,0 +1,271 @@ +// Area c, settings: app.describeSettings, app.setSettings and +// app.undoSettings over the allow-list in lib/settings-schema.ts. +// +// A change is checked at the moment it is made, the way the settings page +// would show it: the key must be in the allow-list, the value one the +// setting may take there, the row must be shown (its feature gate, its +// page's gate, no-third-party mode, the admin's hidden flag), not locked by +// the admin and within the admin's restriction. It is written through the +// same setters the pages use (updateSetting, setTheme, setLocale, +// setViewMode), so plugin hooks run, the open page shows it and it syncs; +// then it is read back, since another plugin may have stopped it. + +import { useSettingsStore } from '@/stores/settings-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useLocaleStore } from '@/stores/locale-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { settingsHooks } from '../../plugin-hooks'; +import { noThirdPartyTraffic } from '../../third-party-mode'; +import { toastDurationAtLeast } from '../../accessibility-settings'; +import { detectBrowserLocale } from '@/i18n/detect-locale'; +import { getByPath } from '../../settings-search'; +import { + DENIED_SETTINGS, PAGE_GATES, SETTINGS_SCHEMA, isAllowedSettingValue, settingValueLabelKey, + type SettingSchemaEntry, +} from '../../settings-schema'; +import { isPlainRecord, sessionClient } from '../host-api-input'; +import { messagesFor } from '../host-api-app'; +import { issueUndoToken, redeemUndoToken } from './c.undo'; +import type { + SettingDescription, SettingRefusal, SettingValue, SettingsChangeResult, SettingsUndoResult, +} from './c.types'; + +/** Most settings one call names. */ +export const MAX_PLUGIN_SETTINGS = 20; +/** How long a change may wait for another plugin's onBeforeSettingChange. */ +const VETO_WAIT_MS = 3000; + +type Messages = Record; + +export function currentSettingValue(entry: SettingSchemaEntry): SettingValue | null { + let value: unknown; + switch (entry.store) { + case 'theme': value = (useThemeStore.getState() as unknown as { theme?: unknown }).theme; break; + case 'locale': value = useLocaleStore.getState().locale; break; + case 'calendar': value = useCalendarStore.getState().viewMode; break; + default: value = (useSettingsStore.getState() as unknown as Record)[entry.key]; + } + if (Array.isArray(value)) return [...value] as SettingValue; + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : null; +} + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +function gateOn(gate: Parameters['isFeatureEnabled']>[0]): boolean { + try { return usePolicyStore.getState().isFeatureEnabled(gate); } catch { return true; } +} + +/** + * Why `key` can't be changed (to `value`, when given) right now, or null. + * `pending` holds the other values of the same call (calendar hours are + * checked against each other). + */ +export function settingRefusal(key: string, value?: unknown, pending: Record = {}): SettingRefusal | null { + const entry = Object.prototype.hasOwnProperty.call(SETTINGS_SCHEMA, key) ? SETTINGS_SCHEMA[key] : undefined; + if (!entry) return DENIED_SETTINGS.includes(key) || key in (useSettingsStore.getState() as object) ? 'not_allowed' : 'unknown'; + const policy = usePolicyStore.getState(); + if (entry.gate && !gateOn(entry.gate)) return 'unavailable'; + const pageGate = PAGE_GATES[entry.page]; + if (pageGate && !gateOn(pageGate)) return 'unavailable'; + if (entry.thirdParty && noThirdPartyTraffic()) return 'unavailable'; + if (entry.store === 'settings' && policy.isSettingHidden(key)) return 'hidden'; + if (entry.store === 'settings' && policy.isSettingLocked(key) && !entry.preset) return 'locked'; + if (entry.store === 'theme' && policy.getColorModePolicy().locked) return 'locked'; + if (value === undefined) return null; + if (!isAllowedSettingValue(entry, value)) return 'bad_value'; + if (entry.kind.type === 'choice' || entry.kind.type === 'list') { + const values = Array.isArray(value) ? value : [value]; + const gated = entry.kind.options.filter((o) => o.gate && !gateOn(o.gate)); + if (gated.some((o) => values.includes(o.value))) return 'unavailable'; + } + const restriction = entry.store === 'settings' ? policy.getRestriction(key) : undefined; + if (restriction) { + if (restriction.allowedValues && !restriction.allowedValues.some((v) => same(v, value))) return 'bad_value'; + if (typeof value === 'number') { + if (key === 'toastDuration' && typeof restriction.min === 'number') { + if (!toastDurationAtLeast(value, restriction.min)) return 'bad_value'; + } else if (typeof restriction.min === 'number' && value < restriction.min) return 'bad_value'; + if (typeof restriction.max === 'number' && value > restriction.max) return 'bad_value'; + } + } + if (key === 'calendarDayStartHour' || key === 'calendarDayEndHour') { + const state = useSettingsStore.getState(); + const start = Number(key === 'calendarDayStartHour' ? value : pending.calendarDayStartHour ?? state.calendarDayStartHour); + const end = Number(key === 'calendarDayEndHour' ? value : pending.calendarDayEndHour ?? state.calendarDayEndHour); + if (!(end > start)) return 'bad_value'; + } + return null; +} + +function format(messages: Messages, key: string, params: Record = {}): string | null { + const raw = getByPath(messages, key); + if (typeof raw !== 'string') return null; + return raw.replace(/\{(\w+)\}/g, (all, name: string) => params[name] ?? all); +} + +function userLocale(): string { + return useLocaleStore.getState().locale || detectBrowserLocale('en'); +} + +/** A value in the user's words: the page's own label, else a formatted value. */ +export function settingValueLabel(entry: SettingSchemaEntry, value: unknown, messages: Messages, locale = userLocale()): string { + if (value === null || value === undefined) return ''; + const key = settingValueLabelKey(entry, value); + if (key) { + const zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } })(); + const text = format(messages, key, { zone }); + if (text) return text; + } + const kind = entry.kind; + if ((kind.type === 'choice' || kind.type === 'list')) { + const label = (v: unknown) => { + const option = kind.options.find((o) => o.value === v); + return (option?.label && format(messages, option.label)) || option?.text || String(v); + }; + return Array.isArray(value) ? value.map(label).join(', ') : label(value); + } + if (kind.type === 'hour' && typeof value === 'number') return `${String(value).padStart(2, '0')}:00`; + if (kind.type === 'days' && Array.isArray(value)) { + // 2023-01-01 was a Sunday (day 0). + return value.map((d) => { + try { return new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + Number(d)))); } catch { return String(d); } + }).join(', '); + } + if (kind.type === 'locale' && typeof value === 'string' && value && value !== 'auto') { + try { return new Intl.DisplayNames([value], { type: 'language' }).of(value) ?? value; } catch { return value; } + } + return Array.isArray(value) ? value.join(', ') : String(value); +} + +function readChanges(arg: unknown, method: string): Record { + if (!isPlainRecord(arg)) throw new Error(`${method}: changes must be an object of setting names and values`); + const keys = Object.keys(arg); + if (keys.length === 0 || keys.length > MAX_PLUGIN_SETTINGS) throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`); + return arg; +} + +/** + * The settings named, in the user's language: label, page, current value + * and, for a change, the new one, and whether it may be made now. `arg` is + * an object of changes or an array of setting names. + */ +export async function doDescribeSettings(arg: unknown): Promise> { + const method = 'app.describeSettings'; + const changes: Record = Array.isArray(arg) + ? Object.fromEntries(arg.slice(0, MAX_PLUGIN_SETTINGS + 1).map((k) => [String(k), undefined])) + : readChanges(arg, method); + if (Object.keys(changes).length === 0 || Object.keys(changes).length > MAX_PLUGIN_SETTINGS) { + throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`); + } + const locale = userLocale(); + const messages = await messagesFor(locale); + const out: Record = {}; + for (const [key, next] of Object.entries(changes)) { + if (key.length > 100) continue; + const reason = settingRefusal(key, next, changes); + const entry = Object.prototype.hasOwnProperty.call(SETTINGS_SCHEMA, key) ? SETTINGS_SCHEMA[key] : undefined; + if (!entry) { + out[key] = { key, label: key, page: '', pageLabel: '', current: null, currentLabel: '', allowed: false, reason: reason ?? 'unknown' }; + continue; + } + const current = currentSettingValue(entry); + const options = entry.kind.type === 'choice' || entry.kind.type === 'boolean' + ? (entry.kind.type === 'boolean' ? [true, false] : entry.kind.options.map((o) => o.value)) + .filter((v) => settingRefusal(key, v, changes) === null) + .map((v) => ({ value: v, label: settingValueLabel(entry, v, messages, locale) })) + : undefined; + out[key] = { + key, + label: format(messages, entry.label) ?? key, + page: entry.page, + pageLabel: format(messages, `settings.tabs.${entry.page}`) ?? entry.page, + current, + currentLabel: settingValueLabel(entry, current, messages, locale), + ...(next !== undefined ? { next: next as SettingValue, nextLabel: settingValueLabel(entry, next, messages, locale) } : {}), + allowed: reason === null, + ...(reason ? { reason } : {}), + ...(options && options.length ? { options } : {}), + }; + } + return out; +} + +/** Writes one value through the setter the settings page uses. */ +function write(entry: SettingSchemaEntry, value: unknown): void { + switch (entry.store) { + case 'theme': useThemeStore.getState().setTheme(value as 'light' | 'dark' | 'system'); return; + case 'locale': useLocaleStore.getState().setLocale(value as string); return; + case 'calendar': useCalendarStore.getState().setViewMode(value as never); return; + default: useSettingsStore.getState().updateSetting(entry.key as never, value as never); + } +} + +/** Writes and reads back: false when the value didn't stick (another plugin stopped it). */ +async function writeAndCheck(entry: SettingSchemaEntry, value: unknown): Promise { + write(entry, value); + if (same(currentSettingValue(entry), value)) return true; + // With another plugin's onBeforeSettingChange the store applies the change + // once that plugin answered, or never. + if (entry.store !== 'settings' || settingsHooks.onBeforeSettingChange.size === 0) return false; + const until = Date.now() + VETO_WAIT_MS; + while (Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (same(currentSettingValue(entry), value)) return true; + } + return false; +} + +/** + * Change settings for the user. Each key is checked and written on its own; + * the result says which were applied (old and new value) and which were + * refused and why, plus a token for app.undoSettings. + */ +export async function doSetSettings(pluginId: string, arg: unknown): Promise { + const method = 'app.setSettings'; + const changes = readChanges(arg, method); + const client = sessionClient(method); + const applied: SettingsChangeResult['applied'] = {}; + const refused: SettingsChangeResult['refused'] = {}; + const previous: Record = {}; + for (const [key, value] of Object.entries(changes)) { + const reason = settingRefusal(key, value, changes); + if (reason) { refused[key] = reason; continue; } + const entry = SETTINGS_SCHEMA[key]; + const old = currentSettingValue(entry); + if (same(old, value)) { applied[key] = { old, new: value as SettingValue }; continue; } + if (!(await writeAndCheck(entry, value))) { refused[key] = 'stopped'; continue; } + applied[key] = { old, new: value as SettingValue }; + previous[key] = old; + } + const undoToken = Object.keys(previous).length > 0 + ? issueUndoToken(pluginId, client.getAccountId(), 'settings', previous) + : null; + return { applied, refused, undoToken }; +} + +/** + * Put back what app.setSettings changed: the exact old value, even one the + * allow-list wouldn't offer (the user chose it), but not where the admin + * has locked the setting since. + */ +export async function doUndoSettings(pluginId: string, token: unknown): Promise { + const method = 'app.undoSettings'; + const client = sessionClient(method); + const previous = redeemUndoToken>(method, pluginId, client.getAccountId(), 'settings', token); + const restored: string[] = []; + const refused: SettingsUndoResult['refused'] = {}; + const policy = usePolicyStore.getState(); + for (const [key, old] of Object.entries(previous)) { + const entry = SETTINGS_SCHEMA[key]; + if (!entry || old === null) { refused[key] = 'unknown'; continue; } + if ((entry.store === 'settings' && policy.isSettingLocked(key) && !entry.preset) + || (entry.store === 'theme' && policy.getColorModePolicy().locked)) { + refused[key] = 'locked'; + continue; + } + if (same(currentSettingValue(entry), old) || (await writeAndCheck(entry, old))) restored.push(key); + else refused[key] = 'stopped'; + } + return { restored, refused }; +} diff --git a/lib/plugin-sandbox/assistant/c.ts b/lib/plugin-sandbox/assistant/c.ts index f0fc6c41e..a626b87cc 100644 --- a/lib/plugin-sandbox/assistant/c.ts +++ b/lib/plugin-sandbox/assistant/c.ts @@ -1,18 +1,71 @@ -// Area c of the assistant's host APIs, host side (see README.md). +// Area c of the assistant's host APIs, host side (see README.md): answering +// invitations, deleting events with undo, filter rules, batches of +// messages, the out-of-office's forward and changing settings. +// +// Two existing calls are taken over here and keep their permission and +// their behaviour for every plugin: calendar.deleteEvent now also returns +// an undo token, and vacation.get also says whether the out-of-office +// forwards mail. -import type { InstalledPlugin, Permission } from '../../plugin-types'; +import type { InstalledPlugin, Permission, VacationView } from '../../plugin-types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { doVacationGet } from '../host-api-settings'; import { NOT_MINE } from './not-mine'; -import type { AreaHostFeaturesC } from './c.types'; +import { doCalendarDeleteWithUndo, doCalendarRespond, doCalendarUndoDelete } from './c.calendar'; +import { doFiltersCreate, doFiltersList, doFiltersUndo } from './c.filters'; +import { doEmailBatch, doEmailUndoBatch } from './c.batch'; +import { doDescribeSettings, doSetSettings, doUndoSettings } from './c.settings'; +import type { AreaHostFeaturesC, VacationForwardField } from './c.types'; /** Method → permission, joined into PERM_PER_METHOD. */ -export const PERMS: Readonly> = {}; +export const PERMS: Readonly> = { + 'calendar.respond': 'calendar:respond', + 'calendar.deleteEvent': 'calendar:write', + 'calendar.undoDelete': 'calendar:write', + 'filters.list': 'filters:read', + 'filters.create': 'filters:write', + 'filters.undo': 'filters:write', + 'email.batch': 'email:write', + 'email.undoBatch': 'email:write', + 'vacation.get': 'vacation:read', + 'app.describeSettings': 'settings:read', + 'app.setSettings': 'settings:change', + 'app.undoSettings': 'settings:change', +}; + +/** vacation.get, plus whether the out-of-office forwards mail while it is on. */ +async function doVacationGetWithForward(): Promise { + const view = await doVacationGet(); + return { ...view, forwardActive: useVacationStore.getState().forward?.enabled === true }; +} /** Runs a method of this area; NOT_MINE for any other. */ -export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise { - return NOT_MINE; +export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise { + switch (method) { + case 'calendar.respond': return doCalendarRespond(args[0], args[1]); + case 'calendar.deleteEvent': return doCalendarDeleteWithUndo(plugin.id, args[0]); + case 'calendar.undoDelete': return doCalendarUndoDelete(plugin.id, args[0]); + case 'filters.list': return doFiltersList(); + case 'filters.create': return doFiltersCreate(plugin.id, args[0]); + case 'filters.undo': return doFiltersUndo(plugin.id, args[0]); + case 'email.batch': return doEmailBatch(plugin.id, args[0]); + case 'email.undoBatch': return doEmailUndoBatch(plugin.id, args[0]); + case 'vacation.get': return doVacationGetWithForward(); + case 'app.describeSettings': return doDescribeSettings(args[0]); + case 'app.setSettings': return doSetSettings(plugin.id, args[0]); + case 'app.undoSettings': return doUndoSettings(plugin.id, args[0]); + default: return NOT_MINE; + } } /** This area's flags in api.host.features, right now. */ export function features(): AreaHostFeaturesC { - return {}; + const client = useAuthStore.getState().client; + return { + answerInvitation: true, + filterRules: client?.supportsSieve?.() === true, + batchMail: true, + changeSettings: true, + }; } diff --git a/lib/plugin-sandbox/assistant/c.types.ts b/lib/plugin-sandbox/assistant/c.types.ts index 17d7ab25b..7a5c6c269 100644 --- a/lib/plugin-sandbox/assistant/c.types.ts +++ b/lib/plugin-sandbox/assistant/c.types.ts @@ -1,8 +1,173 @@ -// Area c of the assistant's host APIs: its permissions and its flags in -// api.host.features (see README.md). Pure: no imports from the app. +// Area c of the assistant's host APIs: its permissions, its flags in +// api.host.features and the shapes its calls take and return (see +// README.md). Pure: no imports from the app. -/** Permissions this area adds to ALL_PERMISSIONS. */ -export const PERMISSIONS = [] as const; +/** + * Permissions this area adds to ALL_PERMISSIONS. Both are high risk: + * answering an invitation sends a reply to the organiser, and changing + * settings changes how the app behaves for the user. + */ +export const PERMISSIONS = ['calendar:respond', 'settings:change'] as const; -/** Flags this area adds to api.host.features. */ -export interface AreaHostFeaturesC {} +/** Flags this area adds to api.host.features (missing on older hosts). */ +export interface AreaHostFeaturesC { + /** calendar.respond answers an invitation the way the reader's banner does. */ + answerInvitation?: boolean; + /** filters.list / create / undo over Settings > Filters (the server supports Sieve). */ + filterRules?: boolean; + /** email.batch / undoBatch: one change to many messages, one Undo. */ + batchMail?: boolean; + /** app.describeSettings / setSettings / undoSettings. */ + changeSettings?: boolean; +} + +// ─── Invitations ────────────────────────────────────────────── + +export type InvitationAnswer = 'accepted' | 'tentative' | 'declined'; + +/** What calendar.respond answered. */ +export interface InvitationResponseView { + status: InvitationAnswer; + /** The event's title. */ + title: string; + /** Its start as the invitation gives it (local date-time, or a date), or null. */ + start: string | null; + /** The organiser's address, or null when the invitation names none. */ + organizer: string | null; +} + +/** What calendar.deleteEvent returns now: a token for calendar.undoDelete, or null when it can't be undone. */ +export interface EventDeleteResult { + undoToken: string | null; +} + +// ─── Filters ────────────────────────────────────────────────── + +export type FilterRuleField = 'from' | 'to' | 'cc' | 'subject' | 'body' | 'header' | 'size' | 'attachment'; +export type FilterRuleOp = + | 'contains' | 'not_contains' | 'is' | 'not_is' | 'starts_with' | 'ends_with' + | 'address_is' | 'domain_is' | 'greater_than' | 'less_than' | 'has_any' | 'has_type'; +/** The actions a plugin may put in a rule. Forward, reject, discard and keep don't exist here. */ +export type FilterRuleActionType = 'move' | 'copy' | 'mark_read' | 'star' | 'add_label' | 'stop'; + +export interface FilterRuleConditionInput { + field: FilterRuleField; + op: FilterRuleOp; + /** A string, or several (any of them matches). Size in bytes. None for has_any. */ + value?: string | string[]; + /** For field 'header'. */ + headerName?: string; +} + +export interface FilterRuleActionInput { + type: FilterRuleActionType; + /** move / copy: a folder of the filters' own account, not a shared one. */ + mailboxId?: string; + /** add_label: the id of one of the user's tags (keywords.list). */ + tagId?: string; +} + +/** filters.create's rule. */ +export interface FilterRuleInput { + name: string; + /** All conditions must match (default) or any one. */ + match?: 'all' | 'any'; + conditions: FilterRuleConditionInput[]; + actions: FilterRuleActionInput[]; +} + +/** A rule as filters.list shows it. */ +export interface FilterRuleInfo { + id: string; + name: string; + isActive: boolean; + /** False for rules written elsewhere (kept as they are). */ + editable: boolean; + match: 'all' | 'any'; + conditions: { field: string; op: string; value: string | string[]; headerName?: string }[]; + /** `folder` is the folder's path for move/copy, `tagId` the tag of add_label, `value` the address of a forward. */ + actions: { type: string; mailboxId?: string; folder?: string; tagId?: string; value?: string }[]; +} + +export interface FiltersListView { + /** The server has filters (Sieve). */ + supported: boolean; + /** Rules can be added: false when the script was edited by hand. */ + editable: boolean; + rules: FilterRuleInfo[]; +} + +export interface FilterCreateResult { + id: string; + undoToken: string; +} + +// ─── Batches ────────────────────────────────────────────────── + +export type MailBatchAction = 'archive' | 'move' | 'read' | 'unread' | 'tag' | 'untag'; + +export interface MailBatchInput { + /** Messages of the signed-in account, at most 200. */ + emailIds: string[]; + action: MailBatchAction; + /** move: a folder of the signed-in account, not a shared one and not the trash. */ + mailboxId?: string; + /** tag / untag: one of the user's tags. */ + tagId?: string; +} + +export interface MailBatchResult { + /** Messages changed. */ + done: number; + /** Messages not found or not changed. */ + failed: number; + /** For email.undoBatch; null when nothing changed. */ + undoToken: string | null; +} + +// ─── Out-of-office ──────────────────────────────────────────── + +/** What vacation.get adds to VacationView. */ +export interface VacationForwardField { + /** The out-of-office forwards mail to another address while it is on (set in Settings > Out of office). */ + forwardActive: boolean; +} + +// ─── Settings ───────────────────────────────────────────────── + +export type SettingValue = string | number | boolean | (string | number)[]; + +/** Why a setting can't be changed. */ +export type SettingRefusal = 'unknown' | 'not_allowed' | 'bad_value' | 'locked' | 'hidden' | 'unavailable' | 'stopped'; + +/** One setting, in the user's language (app.describeSettings). */ +export interface SettingDescription { + key: string; + /** The setting's name as its row shows it. */ + label: string; + /** The settings page it is on (its id and its name). */ + page: string; + pageLabel: string; + current: SettingValue | null; + currentLabel: string; + /** The value asked about, when one was given. */ + next?: SettingValue; + nextLabel?: string; + /** Whether the change may be made now (for a key alone: whether the setting may be changed). */ + allowed: boolean; + reason?: SettingRefusal; + /** The values it may take, for a setting with a short list of them. */ + options?: { value: string | number | boolean; label: string }[]; +} + +export interface SettingsChangeResult { + applied: Record; + refused: Record; + /** For app.undoSettings; null when nothing changed. */ + undoToken: string | null; +} + +export interface SettingsUndoResult { + restored: string[]; + refused: Record; +} diff --git a/lib/plugin-sandbox/assistant/c.undo.ts b/lib/plugin-sandbox/assistant/c.undo.ts new file mode 100644 index 000000000..cf1d84702 --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.undo.ts @@ -0,0 +1,57 @@ +// Undo tokens for area c (email.batch, filters.create, calendar.deleteEvent, +// app.setSettings). What an undo needs stays in the host: the plugin only +// gets a random token, bound to its id and the signed-in account, good for +// 30 minutes. So a plugin can take back what it did, but can never hand the +// host a copy of something to re-create. + +import { generateUUID } from '../../utils'; + +/** How long an undo token can be used. */ +export const UNDO_TOKEN_TTL_MS = 30 * 60_000; +/** Most tokens kept per plugin; the oldest go first. */ +const MAX_TOKENS_PER_PLUGIN = 50; + +interface Entry { + pluginId: string; + accountId: string; + kind: string; + expires: number; + data: unknown; +} + +const tokens = new Map(); + +function prune(now: number): void { + for (const [token, entry] of tokens) if (entry.expires <= now) tokens.delete(token); +} + +/** Keeps `data` for an undo of `kind` and returns its token. */ +export function issueUndoToken(pluginId: string, accountId: string, kind: string, data: unknown): string { + const now = Date.now(); + prune(now); + const mine = [...tokens].filter(([, e]) => e.pluginId === pluginId); + for (const [token] of mine.slice(0, Math.max(0, mine.length - MAX_TOKENS_PER_PLUGIN + 1))) tokens.delete(token); + const token = generateUUID(); + tokens.set(token, { pluginId, accountId, kind, expires: now + UNDO_TOKEN_TTL_MS, data }); + return token; +} + +/** + * Takes the data of a token: only for the plugin that got it, in the same + * account, for the same kind, before it ran out. A token works once. + */ +export function redeemUndoToken(method: string, pluginId: string, accountId: string, kind: string, token: unknown): T { + if (typeof token !== 'string' || !token) throw new Error(`${method}: undoToken must be a string`); + const entry = tokens.get(token); + prune(Date.now()); + if (!entry || entry.expires <= Date.now() || entry.pluginId !== pluginId || entry.accountId !== accountId || entry.kind !== kind) { + throw new Error(`${method}: this can't be undone any more`); + } + tokens.delete(token); + return entry.data as T; +} + +/** For tests. */ +export function clearUndoTokens(): void { + tokens.clear(); +} diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts index 2c545c609..a753f11ba 100644 --- a/lib/plugin-sandbox/host-api-app.ts +++ b/lib/plugin-sandbox/host-api-app.ts @@ -10,6 +10,7 @@ import { detectBrowserLocale } from '@/i18n/detect-locale'; import { useLocaleStore } from '@/stores/locale-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useThemeStore } from '@/stores/theme-store'; +import { useCalendarStore } from '@/stores/calendar-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; import { fetchConfig, type ConfigData } from '@/hooks/use-config'; @@ -132,15 +133,42 @@ export const SHARED_SETTINGS = [ 'showWeekNumbers', 'calendarWorkingDays', 'enableCalendarTasks', 'showBirthdayCalendar', 'emailNotificationsEnabled', 'emailNotificationSound', 'calendarNotificationsEnabled', 'enableUnifiedMailbox', 'sessionTimeout', + // Everything else app.setSettings may change (lib/settings-schema.ts), so + // a plugin reads the current value before it proposes a new one. + 'colorfulSidebarIcons', 'showFolderTotalCount', 'hideAccountSwitcher', 'showRailAccountList', + 'unifiedCrossAccount', 'includeGroupInUnified', 'enableCrossUnreadView', 'enableCrossStarredView', 'enableCrossAllView', + 'tintListRowsByAccount', 'showAccountDot', 'showSharedMailAccounts', 'compactSharedMailboxes', 'showAdvancedSettings', + 'dateLocale', 'protocolOpenMode', 'showOnboardingOnNewDevices', + 'mvInboxType', 'messageListOrderScope', 'tintListRowsByTag', 'senderFavicons', 'showAvatarsInJunk', + 'hoverActions', 'hoverActionsMode', 'hoverActionsCorner', 'swipeRightAction', 'swipeLeftAction', + 'searchDefaultScope', 'clearSearchOnFolderChange', + 'messageSpacing', 'attachmentImagePreviewsEnabled', 'hideInlineImageAttachments', + 'filenameSpaceReplacement', 'filenameLowercase', 'filenameStripDiacritics', 'filenameCollapseSeparators', 'postExportAction', + 'recipientMentionsEnabled', 'mentionAddsRecipientTo', 'rtlEditingSupport', 'quickReplyEnabled', 'replyIdentityMatch', + 'attachmentReminderEnabled', 'attachmentReminderKeywords', 'emptySubjectWarningEnabled', 'placeholderWarningEnabled', + 'notificationSoundChoice', 'pushNotifyInboxOnly', 'faviconUnreadBadge', 'calendarNotificationSound', 'doNotDisturb', 'visualAlerts', + 'calendarInvitationParsingEnabled', 'showTimeInMonthView', 'calendarTintedEvents', 'calendarMonthTimedEvents', + 'calendarLimitHours', 'calendarDayStartHour', 'calendarDayEndHour', 'calendarHideNonWorkingDays', + 'calendarFreeScroll', 'calendarHoverPreview', 'showTasksOnCalendar', + 'groupContactsByLetter', 'sortContactsByLastName', 'showContactsDirectory', + 'showAccessibilityButton', 'lineSpacing', 'letterSpacing', 'wordSpacing', 'paragraphSpacing', 'readingWidth', + 'readingMask', 'plainLanguage', 'reduceTransparency', 'showColorNames', 'colorBlindPalette', 'pauseAnimatedImages', + 'focusAlwaysVisible', 'largeCursor', 'openOnHover', 'showRowActionButton', 'nativeScrollbars', + 'mailViewMode', 'mailTextScale', 'mailMinFontSize', 'mailOverrideStyles', 'mailFitWidth', 'readAloudRate', + 'composeFontFamily', 'composeFontSize', 'confirmDelete', 'confirmArchive', 'unreadCountStyle', + 'announceNewMail', 'announceRowPreview', 'simpleMode', 'simpleModePagedList', ] as const; +/** Lists longer than this are cut; the reminder words (29 by default) must come through whole. */ +const MAX_LIST_ITEMS = 50; + export function doAppGetSettings(): AppSettingsView { const state = useSettingsStore.getState() as unknown as Record; const out: AppSettingsView = {}; for (const key of SHARED_SETTINGS) { const value = state[key]; if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) out[key] = value; - else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, 20) as number[] | string[]; + else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, MAX_LIST_ITEMS) as number[] | string[]; } // Plugins written before Reduce motion (v9) read this switch. out.animationsEnabled = state.reduceMotion !== 'on'; @@ -155,5 +183,8 @@ export function doAppGetSettings(): AppSettingsView { if (typeof theme === 'string') out.theme = theme; const locale = useLocaleStore.getState().locale; if (locale) out.language = locale; + // Settings > Calendar > Default view (the calendar store's viewMode). + const calendarView = useCalendarStore.getState().viewMode; + if (typeof calendarView === 'string') out.calendarView = calendarView; return out; } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index b5089b10e..c32306ca4 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -30,6 +30,10 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ // The out-of-office reply goes out to everyone who writes, in the user's // name, with whatever text the plugin set. 'vacation:write', + // Answering an invitation sends a reply to the organiser in the user's name. + 'calendar:respond', + // Changes how the app behaves for the user (lib/settings-schema.ts). + 'settings:change', ]); /** Locale key segment for a permission: `email:read` → `email_read`. */ diff --git a/lib/settings-schema.ts b/lib/settings-schema.ts new file mode 100644 index 000000000..f2759689b --- /dev/null +++ b/lib/settings-schema.ts @@ -0,0 +1,519 @@ +// The settings a plugin may change for the user (app.setSettings, permission +// settings:change), with where each one is shown and which values it may +// take. Built from the settings pages: the label of each row, its page and +// the labels of its values are the page's own locale keys, so the assistant +// asks "Time format: 12-hour → 24-hour" in the words the user sees. +// +// Everything not listed here is refused. What stays out on purpose (privacy, +// anything that sends mail or data elsewhere, deleting, free text that goes +// into sent mail, maps keyed by server ids) is listed in DENIED_SETTINGS, so +// a test can tell a forgotten key from a refused one. +// +// The run-time checks (an admin lock, a hidden row, a restriction's values, +// the feature gate that shows the row) live with the caller +// (lib/plugin-sandbox/assistant/c.settings.ts); this file is the table and +// the pure value checks. + +import type { SettingsSearchTab } from '@/lib/settings-search'; +import type { FeatureGates } from '@/lib/admin/types'; +import { + ACCESSIBILITY_ALLOWED, + FONT_SIZES, + MAIL_MIN_FONT_SIZES, + MAIL_TEXT_SCALES, + READ_ALOUD_RATES, + TOAST_DURATIONS, + UNLOCKABLE_SETTING_KEYS, +} from '@/lib/accessibility-settings'; +import { locales } from '@/i18n/routing'; + +/** Which store holds the setting. */ +export type SettingsStoreName = 'settings' | 'theme' | 'locale' | 'calendar'; + +/** A value the user can pick, with the locale key of its label (null: formatted, e.g. hours). */ +export interface SettingOption { + value: string | number; + label: string | null; + /** Shown as is when the page does not translate it (a font name, "16px"). Only with label null. */ + text?: string; + /** The page offers this value only while the gate is on (the archive choices). */ + gate?: keyof FeatureGates; +} + +export type SettingKind = + /** On or off. `only` limits it to one direction (confirmDelete may only be turned on). */ + | { type: 'boolean'; only?: boolean } + /** One of a fixed list. Values not in `options` are refused. */ + | { type: 'choice'; options: SettingOption[] } + /** A whole hour from min to max (calendar hours). */ + | { type: 'hour'; min: number; max: number } + /** 'auto' or an IANA time zone this browser knows. */ + | { type: 'timeZone' } + /** '' (the browser's) or a locale the app has. */ + | { type: 'locale' } + /** Days of the week 0-6, unique, at least one. */ + | { type: 'days' } + /** A list of short words: trimmed, unique, no line breaks; lowercase when `lowercase` is set. */ + | { type: 'words'; max: number; maxLength: number; lowercase?: boolean } + /** A list of ids from `options`, unique. */ + | { type: 'list'; options: SettingOption[]; min?: number }; + +export interface SettingSchemaEntry { + key: string; + store: SettingsStoreName; + /** The settings page the row is on. */ + page: SettingsSearchTab; + /** Locale key of the row's label. */ + label: string; + kind: SettingKind; + /** The feature gate that shows the row; the change is refused while it is off. */ + gate?: keyof FeatureGates; + /** The row is hidden in no-third-party mode (it fetches from elsewhere). */ + thirdParty?: boolean; + /** An admin lock acts as a default for this key (UNLOCKABLE_SETTING_KEYS), so it doesn't refuse. */ + preset?: boolean; + /** The label says the opposite of the value (disableThreading: "Group conversations"). */ + inverted?: boolean; +} + +/** Locale keys of the labels for on and off. */ +export const ON_LABEL = 'settings.accessibility.on'; +export const OFF_LABEL = 'settings.accessibility.off'; + +/** + * Pages the settings app shows only while a feature gate is on. A key on + * such a page is refused while the gate is off, on top of its own `gate`. + */ +export const PAGE_GATES: Readonly>> = { + calendar: 'calendarEnabled', + contacts: 'contactsEnabled', + files: 'filesEnabled', +}; + +// The ids below live in modules that import the settings store (the hover +// actions, the month view styles) or the editor (the compose sizes). They are +// copied here so this table stays free of stores; the test checks each copy +// against its source. + +/** ALL_HOVER_ACTIONS in stores/settings-store.ts, with their labelKey. */ +export const HOVER_ACTION_IDS = [ + ['delete', 'delete'], ['star', 'star'], ['markRead', 'mark_read'], ['archive', 'archive'], ['tag', 'tag'], ['spam', 'spam'], +] as const; +/** MONTH_TIMED_EVENT_STYLES in stores/settings-store.ts. */ +export const MONTH_TIMED_EVENT_STYLE_IDS = ['bar', 'text', 'dot'] as const; +/** NOTIFICATION_SOUNDS in lib/notification-sound.ts (it imports lib/debug, which imports the store). */ +export const NOTIFICATION_SOUND_IDS = ['default', 'cheerful', 'involved', 'swift', 'relax'] as const; +/** FONT_SIZES in components/email/font-size.ts (the composer's sizes). */ +export const COMPOSE_FONT_SIZE_IDS = ['14px', '16px', '18px', '20px', '24px', '32px'] as const; + +// --- Builders ---------------------------------------------------------------- + +type Extra = Pick & { store?: SettingsStoreName }; + +function entry(key: string, page: SettingsSearchTab, label: string, kind: SettingKind, extra: Extra = {}): SettingSchemaEntry { + const { store = 'settings', ...rest } = extra; + const out: SettingSchemaEntry = { key, store, page, label, kind, ...rest }; + if (store === 'settings' && UNLOCKABLE_SETTING_KEYS.has(key)) out.preset = true; + return out; +} + +function bool(key: string, page: SettingsSearchTab, label: string, extra: Extra & { only?: boolean } = {}): SettingSchemaEntry { + const { only, ...rest } = extra; + return entry(key, page, label, only === undefined ? { type: 'boolean' } : { type: 'boolean', only }, rest); +} + +/** Options whose label key is `prefix` + a suffix (the value itself unless mapped). */ +function opts(prefix: string, values: readonly (string | number)[], suffix: Record = {}): SettingOption[] { + return values.map((value) => ({ value, label: prefix + (suffix[String(value)] ?? String(value)) })); +} + +function choice(key: string, page: SettingsSearchTab, label: string, options: SettingOption[], extra: Extra = {}): SettingSchemaEntry { + return entry(key, page, label, { type: 'choice', options }, extra); +} + +/** The values ACCESSIBILITY_ALLOWED lists for `key`, so the table can't drift from the import check. */ +function allowed(key: string): readonly (string | number)[] { + const values = ACCESSIBILITY_ALLOWED[key]; + if (!values) throw new Error(`settings-schema: no ACCESSIBILITY_ALLOWED list for ${key}`); + return values as readonly (string | number)[]; +} + +const A = 'settings.accessibility.'; +const AP = 'settings.appearance.'; +const EB = 'settings.email_behavior.'; +const LR = 'settings.language_region.'; +const CAL = 'calendar.settings.'; +const DL = 'settings.downloads.'; +const NO = 'settings.notifications.'; + +/** Off / On / System radio of the accessibility page. */ +const systemToggle = (key: string) => opts(A, allowed(key)); + +const SWIPE_OPTIONS: SettingOption[] = [ + { value: 'none', label: `${EB}swipe_actions.none` }, + { value: 'archive', label: `${EB}swipe_actions.archive`, gate: 'archiveActionEnabled' }, + { value: 'delete', label: `${EB}swipe_actions.delete` }, + { value: 'markRead', label: `${EB}swipe_actions.mark_read` }, + { value: 'star', label: `${EB}swipe_actions.star` }, + { value: 'spam', label: `${EB}swipe_actions.spam` }, +]; + +const ENTRIES: SettingSchemaEntry[] = [ + // --- Appearance --- + choice('theme', 'appearance', `${AP}theme.label`, opts(`${AP}theme.`, ['light', 'dark', 'system']), { store: 'theme' }), + choice('density', 'appearance', `${AP}list_density.label`, + opts(`${AP}list_density.`, ['extra-compact', 'compact', 'regular', 'comfortable'], { 'extra-compact': 'extra_compact' })), + choice('interfaceLayout', 'appearance', `${AP}interface_layout.label`, + opts(`${AP}interface_layout.`, ['default', 'mountain-view'], { 'mountain-view': 'mountain_view' })), + choice('mailLayout', 'appearance', `${EB}mail_layout.label`, opts(`${EB}mail_layout.`, ['split', 'focus', 'horizontal'])), + choice('toolbarPosition', 'appearance', `${AP}toolbar_position.label`, + opts(`${AP}toolbar_position.`, ['top', 'below-subject'], { 'below-subject': 'below_subject' })), + bool('showToolbarLabels', 'appearance', `${AP}toolbar_labels.label`), + bool('colorfulSidebarIcons', 'appearance', `${AP}colorful_sidebar_icons.label`), + bool('showFolderTotalCount', 'appearance', `${AP}show_folder_total_count.label`), + bool('hideAccountSwitcher', 'appearance', `${AP}hide_account_switcher.label`), + bool('showRailAccountList', 'appearance', `${AP}show_rail_account_list.label`), + bool('proInterface', 'appearance', `${AP}pro_interface.label`), + bool('enableUnifiedMailbox', 'appearance', `${AP}unified_mailbox.label`), + bool('unifiedCrossAccount', 'appearance', `${AP}unified_mailbox.cross_account.label`, { gate: 'unifiedCrossAccountEnabled' }), + bool('includeGroupInUnified', 'appearance', `${AP}unified_mailbox.include_group.label`), + bool('enableCrossUnreadView', 'appearance', `${AP}cross_unread.label`, { gate: 'crossUnreadViewEnabled' }), + bool('enableCrossStarredView', 'appearance', `${AP}cross_starred.label`, { gate: 'crossStarredViewEnabled' }), + bool('enableCrossAllView', 'appearance', `${AP}cross_all.label`, { gate: 'crossAllViewEnabled' }), + // One radio on the page (dot / tint / none) writes both keys. + bool('tintListRowsByAccount', 'appearance', `${AP}account_marker.label`), + bool('showAccountDot', 'appearance', `${AP}account_marker.label`), + bool('showSharedMailAccounts', 'appearance', `${AP}show_shared_mail_accounts.label`), + bool('compactSharedMailboxes', 'appearance', `${AP}compact_shared_mailboxes.label`), + // The sidebar switch of the settings app; it has no page of its own. + bool('showAdvancedSettings', 'general', 'settings.advanced_switch.label'), + + // --- Language and region, General --- + entry('locale', 'general', `${LR}language.label`, { type: 'locale' }, { store: 'locale' }), + choice('dateFormat', 'general', `${LR}date_format.label`, opts(`${LR}date_format.`, ['smart', 'relative', 'full'])), + choice('dateLocale', 'general', `${LR}date_locale.label`, + opts(`${LR}date_locale.`, ['auto', 'iso', 'en-GB', 'en-US'], { 'en-GB': 'dmy', 'en-US': 'mdy' })), + choice('timeFormat', 'general', `${LR}time_format.label`, opts(`${LR}time_format.`, ['12h', '24h'])), + entry('timeZone', 'general', `${LR}time_zone.label`, { type: 'timeZone' }), + choice('firstDayOfWeek', 'general', `${LR}first_day.label`, + opts('calendar.days.', [1, 6, 0], { 1: 'monday', 6: 'saturday', 0: 'sunday' })), + choice('protocolOpenMode', 'general', 'protocol_handlers.protocol_open_mode_label', + opts('protocol_handlers.protocol_open_mode_', ['new-tab', 'active-session'], { 'new-tab': 'new_tab', 'active-session': 'active_session' })), + bool('showOnboardingOnNewDevices', 'general', 'tour.show_on_new_devices_title'), + + // --- Message list --- + bool('disableThreading', 'message_list', `${EB}group_conversations.label`, { inverted: true }), + bool('showPreview', 'message_list', `${EB}show_preview.label`), + bool('listColumns', 'message_list', `${EB}list_columns.label`), + choice('mvInboxType', 'message_list', `${EB}inbox_type.label`, + opts(`${EB}inbox_type.`, ['default', 'unread-first'], { 'unread-first': 'unread_first' })), + // A switch on the page: on = every folder. + choice('messageListOrderScope', 'message_list', `${AP}message_list_order.scope.label`, [ + { value: 'inbox', label: OFF_LABEL }, + { value: 'all', label: ON_LABEL }, + ]), + bool('tintListRowsByTag', 'message_list', `${AP}tint_list_rows.label`), + bool('showVerificationCodes', 'message_list', `${EB}show_verification_codes.label`), + bool('senderFavicons', 'message_list', 'settings.advanced.sender_favicons.label', { thirdParty: true }), + bool('showAvatarsInJunk', 'message_list', 'settings.advanced.show_avatars_in_junk.label', { thirdParty: true }), + entry('hoverActions', 'message_list', `${EB}hover_actions.label`, { + type: 'list', + options: HOVER_ACTION_IDS.map(([value, labelKey]) => ({ + value, + label: `${EB}hover_actions.${labelKey}`, + ...(value === 'archive' ? { gate: 'archiveActionEnabled' as const } : {}), + })), + }, { gate: 'hoverActionsConfigEnabled' }), + choice('hoverActionsMode', 'message_list', `${EB}hover_actions.mode_label`, + opts(`${EB}hover_actions.mode_`, ['inline', 'floating']), { gate: 'hoverActionsConfigEnabled' }), + choice('hoverActionsCorner', 'message_list', `${EB}hover_actions.corner_label`, + opts(`${EB}hover_actions.corner_`, ['top-left', 'top-right', 'bottom-left', 'bottom-right']), { gate: 'hoverActionsConfigEnabled' }), + choice('swipeRightAction', 'message_list', `${EB}swipe_right_action.label`, SWIPE_OPTIONS), + choice('swipeLeftAction', 'message_list', `${EB}swipe_left_action.label`, SWIPE_OPTIONS), + // No "permanent": it makes every later delete final. + choice('deleteAction', 'message_list', `${EB}delete_action.label`, + opts(`${EB}delete_action.`, ['trash', 'trash-and-read'], { 'trash-and-read': 'trash_and_read' })), + choice('archiveMode', 'message_list', `${EB}archive_mode.label`, opts(`${EB}archive_mode.`, ['single', 'year', 'month']), + { gate: 'archiveActionEnabled' }), + // Delete safety nets: a plugin may turn them on, never off. + bool('confirmBulkDelete', 'message_list', `${EB}confirm_bulk_delete.label`, { only: true }), + bool('permanentlyDeleteJunk', 'message_list', `${EB}permanently_delete_junk.label`, { only: false }), + choice('emailsPerPage', 'message_list', `${EB}emails_per_page.label`, opts(`${EB}emails_per_page.`, [10, 25, 50, 100])), + choice('searchDefaultScope', 'message_list', `${EB}search_default_scope.label`, opts(`${EB}search_default_scope.`, ['all', 'folder'])), + bool('clearSearchOnFolderChange', 'message_list', `${EB}clear_search_on_folder_change.label`), + + // --- Reading --- + choice('markAsReadDelay', 'reading', `${EB}mark_read.label`, + opts(`${EB}mark_read.`, [0, 3000, 5000, -1], { 0: 'instant', 3000: 'delay_3s', 5000: 'delay_5s', '-1': 'never' })), + bool('returnToListAfterAction', 'reading', `${EB}return_to_list_after_action.label`), + choice('messageSpacing', 'reading', `${EB}message_spacing.label`, opts(`${EB}message_spacing.`, ['auto', 'always', 'edge'])), + bool('emailAlwaysLightMode', 'reading', `${EB}always_light_mode.label`), + choice('attachmentPosition', 'reading', `${EB}attachment_position.label`, + opts(`${EB}attachment_position.`, ['below-header', 'below-message'])), + choice('mailAttachmentAction', 'reading', `${EB}attachment_click_action.label`, + opts(`${EB}attachment_click_action.`, ['preview', 'download'])), + bool('attachmentImagePreviewsEnabled', 'reading', `${EB}attachment_image_previews.label`), + bool('hideInlineImageAttachments', 'reading', `${EB}hide_inline_image_attachments.label`), + choice('filenameSpaceReplacement', 'reading', `${DL}spaces.label`, opts(`${DL}spaces.`, ['keep', 'underscore', 'dash'])), + bool('filenameLowercase', 'reading', `${DL}lowercase.label`), + bool('filenameStripDiacritics', 'reading', `${DL}strip_diacritics.label`), + bool('filenameCollapseSeparators', 'reading', `${DL}collapse_separators.label`), + // No "trash": it deletes the exported mail. + choice('postExportAction', 'reading', `${DL}after_export.label`, [ + { value: 'keep', label: `${DL}after_export.keep` }, + { value: 'archive', label: `${DL}after_export.archive`, gate: 'archiveActionEnabled' }, + ]), + + // --- Composing --- + bool('plainTextMode', 'composing', `${EB}plain_text_mode.label`), + bool('recipientMentionsEnabled', 'composing', `${EB}recipient_mentions.label`), + choice('mentionAddsRecipientTo', 'composing', `${EB}mention_adds_recipient.label`, + opts(`${EB}mention_adds_recipient.`, ['to', 'cc', 'off'])), + bool('rtlEditingSupport', 'composing', `${EB}rtl_editing.label`), + bool('quickReplyEnabled', 'composing', `${EB}quick_reply.label`), + bool('autoSelectReplyIdentity', 'composing', `${EB}auto_select_reply_identity.label`), + // The page shows it only while the composer may rewrite From. + choice('replyIdentityMatch', 'composing', `${EB}reply_identity_match.label`, + opts(`${EB}reply_identity_match.`, ['exact', 'domain']), { gate: 'customFromEnabled' }), + choice('signaturePosition', 'composing', `${EB}signature_position.label`, + opts(`${EB}signature_position.`, ['above_quote', 'below_quote'])), + bool('signatureSeparatorEnabled', 'composing', `${EB}signature_separator.label`), + // 10/30/60 read "{seconds} seconds" (send_delay.seconds): formatted. + choice('sendDelaySeconds', 'composing', `${EB}send_delay.label`, [ + { value: 0, label: `${EB}send_delay.off` }, + { value: 10, label: null }, + { value: 30, label: null }, + { value: 60, label: null }, + ]), + bool('sendConfirmation', 'accessibility', `${A}confirm_send.label`), + bool('attachmentReminderEnabled', 'composing', `${EB}attachment_reminder.label`), + // The page trims and lowercases what the user types. + entry('attachmentReminderKeywords', 'composing', `${EB}attachment_reminder.keywords_label`, + { type: 'words', max: 50, maxLength: 40, lowercase: true }), + bool('emptySubjectWarningEnabled', 'composing', `${EB}empty_subject_warning.label`), + bool('placeholderWarningEnabled', 'composing', `${EB}placeholder_warning.label`), + bool('requestReadReceiptDefault', 'composing', `${EB}request_read_receipt.label`), + // No "always": that sends a receipt mail by itself. + choice('readReceiptResponse', 'composing', `${EB}read_receipt_response.label`, + opts(`${EB}read_receipt_response.`, ['ask', 'never'])), + + // --- Notifications --- + bool('emailNotificationsEnabled', 'notifications', `${NO}email.enabled`), + bool('emailNotificationSound', 'notifications', `${NO}email.sound`), + choice('notificationSoundChoice', 'notifications', `${NO}sound_selection.choose`, opts(`${NO}sounds.`, NOTIFICATION_SOUND_IDS)), + bool('pushNotifyInboxOnly', 'notifications', `${NO}email.inbox_only`), + bool('faviconUnreadBadge', 'notifications', `${AP}favicon_unread_badge.label`), + bool('calendarNotificationsEnabled', 'notifications', `${NO}calendar.enabled`), + bool('calendarNotificationSound', 'notifications', `${NO}calendar.sound`), + bool('doNotDisturb', 'accessibility', `${A}do_not_disturb.label`), + bool('visualAlerts', 'accessibility', `${A}visual_alerts.label`), + // Seconds read "{seconds} seconds": formatted. The admin minimum is checked at run time. + choice('toastDuration', 'accessibility', `${A}toast_duration.label`, + TOAST_DURATIONS.map((value) => ({ value, label: value === 0 ? `${A}toast_duration.until_closed` : null }))), + + // --- Calendar --- + choice('viewMode', 'calendar', `${CAL}default_view`, opts('calendar.views.', ['month', 'week', 'day', 'agenda']), { store: 'calendar' }), + bool('calendarInvitationParsingEnabled', 'calendar', `${CAL}invitation_parsing`), + bool('showTimeInMonthView', 'calendar', `${CAL}show_time_in_month_view`), + bool('calendarTintedEvents', 'calendar', `${CAL}tinted_events`), + choice('calendarMonthTimedEvents', 'calendar', `${CAL}month_timed_events`, opts(`${CAL}month_timed_events_`, MONTH_TIMED_EVENT_STYLE_IDS)), + bool('showWeekNumbers', 'calendar', `${CAL}show_week_numbers`), + bool('calendarLimitHours', 'calendar', `${CAL}limit_hours`), + // End after start is checked at run time, across both keys. + entry('calendarDayStartHour', 'calendar', `${CAL}visible_hours`, { type: 'hour', min: 0, max: 23 }), + entry('calendarDayEndHour', 'calendar', `${CAL}visible_hours`, { type: 'hour', min: 1, max: 24 }), + bool('calendarHideNonWorkingDays', 'calendar', `${CAL}hide_non_working_days`), + entry('calendarWorkingDays', 'calendar', `${CAL}working_days`, { type: 'days' }), + bool('calendarFreeScroll', 'calendar', `${CAL}calendar_free_scroll`), + choice('calendarHoverPreview', 'calendar', `${CAL}hover_preview`, + opts(`${CAL}hover_preview_`, ['instant', 'delay-500ms', 'delay-1s', 'delay-2s', 'off'], + { 'delay-500ms': 'delay_500ms', 'delay-1s': 'delay_1s', 'delay-2s': 'delay_2s' })), + bool('showBirthdayCalendar', 'calendar', `${CAL}show_birthday_calendar`), + bool('enableCalendarTasks', 'calendar', `${CAL}enable_tasks`, { gate: 'calendarTasksEnabled' }), + bool('showTasksOnCalendar', 'calendar', `${CAL}show_tasks_on_calendar`, { gate: 'calendarTasksEnabled' }), + + // --- Contacts --- + bool('groupContactsByLetter', 'contacts', 'settings.contacts.group_by_letter_label'), + bool('sortContactsByLastName', 'contacts', 'settings.contacts.sort_by_last_name_label'), + bool('showContactsDirectory', 'contacts', 'settings.contacts.show_directory_label'), + + // --- Accessibility --- + bool('showAccessibilityButton', 'accessibility', `${A}quick_button.label`), + choice('fontSize', 'accessibility', `${A}text_size.label`, opts(`${A}text_size.`, FONT_SIZES)), + choice('appFont', 'accessibility', `${A}font.label`, [ + { value: 'default', label: `${A}font.default` }, + { value: 'atkinson', label: null, text: 'Atkinson Hyperlegible' }, + { value: 'lexend', label: null, text: 'Lexend' }, + { value: 'opendyslexic', label: null, text: 'OpenDyslexic' }, + ]), + choice('plainTextFont', 'accessibility', `${A}plain_text_font.label`, opts(`${A}plain_text_font.`, ['sans', 'mono'])), + choice('lineSpacing', 'accessibility', `${A}line_spacing.label`, opts(`${A}spacing.`, allowed('lineSpacing'))), + choice('letterSpacing', 'accessibility', `${A}letter_spacing.label`, opts(`${A}spacing.`, allowed('letterSpacing'))), + choice('wordSpacing', 'accessibility', `${A}word_spacing.label`, opts(`${A}spacing.`, allowed('wordSpacing'))), + choice('paragraphSpacing', 'accessibility', `${A}paragraph_spacing.label`, opts(`${A}spacing.`, allowed('paragraphSpacing'))), + choice('readingWidth', 'accessibility', `${A}reading_width.label`, opts(`${A}reading_width.`, allowed('readingWidth'))), + bool('readingMask', 'accessibility', `${A}reading_mask.label`), + bool('plainLanguage', 'accessibility', `${A}plain_language.label`), + choice('contrast', 'accessibility', `${A}contrast.label`, [ + { value: 'system', label: `${A}system` }, + { value: 'more', label: `${A}contrast.more` }, + { value: 'normal', label: `${A}contrast.normal` }, + ]), + choice('reduceTransparency', 'accessibility', `${A}transparency.label`, systemToggle('reduceTransparency')), + bool('underlineLinks', 'accessibility', `${A}underline_links.label`), + bool('showColorNames', 'accessibility', `${A}color_names.label`), + bool('colorBlindPalette', 'accessibility', `${A}cvd_palette.label`), + choice('reduceMotion', 'accessibility', `${A}reduce_motion.label`, systemToggle('reduceMotion')), + choice('pauseAnimatedImages', 'accessibility', `${A}pause_images.label`, systemToggle('pauseAnimatedImages')), + bool('singleKeyShortcuts', 'accessibility', `${A}single_key_shortcuts.label`), + choice('focusRing', 'accessibility', `${A}focus_ring.label`, opts(`${A}focus_ring.`, allowed('focusRing'))), + bool('focusAlwaysVisible', 'accessibility', `${A}focus_always.label`), + choice('largeTargets', 'accessibility', `${A}large_targets.label`, systemToggle('largeTargets')), + bool('largeCursor', 'accessibility', `${A}large_cursor.label`), + bool('openOnHover', 'accessibility', `${A}open_on_hover.label`), + bool('showRowActionButton', 'accessibility', `${A}row_action_button.label`), + bool('nativeScrollbars', 'accessibility', `${A}native_scrollbars.label`), + choice('mailViewMode', 'accessibility', `${A}mail_view.label`, opts(`${A}mail_view.`, allowed('mailViewMode'))), + // Percentages: formatted, except 0 ("follow the interface"). + choice('mailTextScale', 'accessibility', `${A}mail_text_size.label`, + MAIL_TEXT_SCALES.map((value) => ({ value, label: value === 0 ? `${A}mail_text_size.follow` : null }))), + // "{size} px": formatted, except 0 ("none"). + choice('mailMinFontSize', 'accessibility', `${A}mail_min_font_size.label`, + MAIL_MIN_FONT_SIZES.map((value) => ({ value, label: value === 0 ? `${A}mail_min_font_size.none` : null }))), + choice('mailOverrideStyles', 'accessibility', `${A}mail_override.label`, + opts(`${A}mail_override.`, allowed('mailOverrideStyles'), { 'high-contrast': 'high_contrast' })), + bool('mailFitWidth', 'accessibility', `${A}mail_fit_width.label`), + // "1.25×": formatted. + choice('readAloudRate', 'accessibility', `${A}read_aloud_rate.label`, READ_ALOUD_RATES.map((value) => ({ value, label: null }))), + // Only the fonts and sizes the page offers: a free CSS value must not reach the composer. + choice('composeFontFamily', 'accessibility', `${A}compose_font.label`, + opts(`${A}compose_font.`, ['', 'sans-serif', 'serif', 'monospace'], { '': 'default', 'sans-serif': 'sans', monospace: 'mono' })), + choice('composeFontSize', 'accessibility', `${A}compose_size.label`, [ + { value: '', label: `${A}compose_size.default` }, + ...COMPOSE_FONT_SIZE_IDS.map((size) => ({ value: size, label: null, text: size })), + ]), + // A delete safety net: on only. + bool('confirmDelete', 'accessibility', `${A}confirm_delete.label`, { only: true }), + bool('confirmArchive', 'accessibility', `${A}confirm_archive.label`), + choice('unreadCountStyle', 'accessibility', `${A}unread_counts.label`, opts(`${A}unread_counts.`, allowed('unreadCountStyle'))), + bool('announceNewMail', 'accessibility', `${A}announce_new_mail.label`), + bool('announceRowPreview', 'accessibility', `${A}announce_preview.label`), + bool('simpleMode', 'accessibility', `${A}simple_mode.label`), + bool('simpleModePagedList', 'accessibility', `${A}simple_mode_paged.label`), +]; + +/** The settings a plugin may change, by key. */ +export const SETTINGS_SCHEMA: Readonly> = Object.freeze( + Object.fromEntries(ENTRIES.map((e) => [e.key, e])), +); + +/** + * Settings refused on purpose (see the file header), grouped by why. + */ +export const DENIED_SETTINGS: readonly string[] = [ + // Privacy: loads remote content, or says who may. + 'externalContentPolicy', 'trustedSenders', 'trustedSendersAddressBook', + // Free text that ends up in file names or in every sent mail. + 'emailDownloadTemplate', 'attachmentDownloadTemplate', 'bundleDownloadTemplate', 'signatureDetails', + // Never read by the app (defaultReplyMode), or on the account page (the delimiter). + 'defaultReplyMode', 'subAddressDelimiter', + // Sends push metadata to another server. + 'pushRelayUrl', + // A browser voice id the model can't know. + 'readAloudVoice', + // Maps and lists keyed by server ids. + 'allMailFolderIds', 'hiddenSharedMailAccounts', 'notificationMutes', 'hiddenCompanyCalendars', 'sharedCalendarColors', + 'folderIcons', 'folderCountModes', 'preferredIdentityIds', + // App bookkeeping and UI state, not preferences. + 'onboardingCompleted', 'tourCompleted', 'mvUnreadSectionCollapsed', 'mvEverythingElseCollapsed', + 'vacationCalendarBlocks', 'explicitSettings', + // Security, debugging and sync. + 'sessionTimeout', 'debugMode', 'debugCategories', 'settingsSyncDisabled', + // Tags (they have their own keywords.* actions), filters and add-ons. + 'emailKeywords', 'nestedTags', 'expandedFilterView', 'sidebarApps', 'keepAppsLoaded', +]; + +/** + * Allowed in principle but not built yet: each needs more than a value + * check. + * - messageListOrder: a list of sort levels (sanitizeSortLevels). + * - filesSettings: an object; each sub-key would need its own row. + * - activeThemeId (theme store): an installed theme id; disabled and forced themes. + * - birthdayCalendarColor: a colour, set in the calendar app, not in Settings. + * - vacationBlockCalendar: the page also adds or removes the busy event + * (vacationBlockChanged); updateSetting alone would not. + */ +export const NOT_YET_SETTINGS: readonly string[] = [ + 'messageListOrder', 'filesSettings', 'activeThemeId', 'birthdayCalendarColor', 'vacationBlockCalendar', +]; + +/** Line breaks, tabs and other control characters. */ +const CONTROL_CHARS = /\p{Cc}/u; + +/** An IANA zone id this runtime knows, spelled as Intl spells it ("Europe/Berlin", "UTC"). */ +function isKnownTimeZone(value: string): boolean { + if (!value || value.length > 64 || !/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(value)) return false; + try { + const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] }; + if (intl.supportedValuesOf?.('timeZone').includes(value)) return true; + } catch { + // fall through to the DateTimeFormat check + } + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone === value; + } catch { + return false; + } +} + +function isUniqueList(value: unknown[]): boolean { + return new Set(value).size === value.length; +} + +/** Whether `value` is one the setting may take (the table's limits only; restrictions are checked at run time). */ +export function isAllowedSettingValue(entry: SettingSchemaEntry, value: unknown): boolean { + const kind = entry.kind; + switch (kind.type) { + case 'boolean': + return typeof value === 'boolean' && (kind.only === undefined || value === kind.only); + case 'choice': + return kind.options.some((option) => option.value === value); + case 'hour': + return typeof value === 'number' && Number.isInteger(value) && value >= kind.min && value <= kind.max; + case 'timeZone': + return typeof value === 'string' && (value === 'auto' || isKnownTimeZone(value)); + case 'locale': + return typeof value === 'string' && (value === '' || value === 'auto' || (locales as readonly string[]).includes(value)); + case 'days': + return Array.isArray(value) && value.length >= 1 && value.length <= 7 && isUniqueList(value) + && value.every((day) => typeof day === 'number' && Number.isInteger(day) && day >= 0 && day <= 6); + case 'words': + return Array.isArray(value) && value.length <= kind.max && isUniqueList(value) + && value.every((word) => typeof word === 'string' && word.length >= 1 && word.length <= kind.maxLength + && word === word.trim() && !CONTROL_CHARS.test(word) && (!kind.lowercase || word === word.toLowerCase())); + case 'list': + return Array.isArray(value) && value.length >= (kind.min ?? 0) && isUniqueList(value) + && value.every((item) => kind.options.some((option) => option.value === item)); + } +} + +/** + * The locale key of the label for `value`: ON_LABEL / OFF_LABEL for a switch + * (turned round for an inverted one), the option's label for a choice, the + * "Automatic" label for timeZone 'auto'. Null when the value is formatted or + * shown as is (hours, seconds, zone ids, languages, lists). + */ +export function settingValueLabelKey(entry: SettingSchemaEntry, value: unknown): string | null { + const kind = entry.kind; + switch (kind.type) { + case 'boolean': + if (typeof value !== 'boolean') return null; + return (entry.inverted ? !value : value) ? ON_LABEL : OFF_LABEL; + case 'choice': + return kind.options.find((option) => option.value === value)?.label ?? null; + case 'timeZone': + return value === 'auto' ? `${LR}time_zone.auto` : null; + default: + return null; + } +} diff --git a/locales/ar/common.json b/locales/ar/common.json index b4e83bbc1..a308ecf07 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5849,6 +5849,10 @@ "title": "تغيير تقويمك", "body": "إنشاء الأحداث وتغييرها وحذفها." }, + "calendar_respond": { + "title": "الرد على الدعوات", + "body": "قبول دعوات التقويم أو رفضها. يصل ردك إلى المنظّم عبر البريد الإلكتروني." + }, "contacts_read": { "title": "قراءة جهات اتصالك", "body": "البحث في دفاتر العناوين وقراءتها." @@ -5914,8 +5918,12 @@ "body": "عرض تفضيلاتك وتعريفات الوسوم." }, "settings_write": { + "title": "تغيير وسومك", + "body": "إضافة وسومك وإعادة تسميتها وإعادة ترتيبها، ومنع التغييرات على إعداداتك." + }, + "settings_change": { "title": "تغيير إعداداتك", - "body": "إضافة الوسوم وإعادة ترتيبها وتغيير تفضيلاتك." + "body": "تغيير مظهر التطبيق وطريقة عمله، مثل السمة وتنسيق الوقت وجزء القراءة. تبقى الإعدادات التي حدّدتها مؤسستك كما هي." }, "security_read": { "title": "عرض أمان حسابك", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0a2460822..64a2e0114 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5849,6 +5849,10 @@ "title": "Canviar el vostre calendari", "body": "Crear, canviar i suprimir esdeveniments." }, + "calendar_respond": { + "title": "Respondre invitacions", + "body": "Acceptar o rebutjar invitacions de calendari. La vostra resposta s'envia a l'organitzador per correu electrònic." + }, "contacts_read": { "title": "Llegir els vostres contactes", "body": "Cercar i llegir les vostres llibretes d'adreces." @@ -5914,8 +5918,12 @@ "body": "Veure les vostres preferències i les definicions d'etiquetes." }, "settings_write": { + "title": "Canviar les vostres etiquetes", + "body": "Afegir, canviar el nom i reordenar les vostres etiquetes, i impedir canvis a la vostra configuració." + }, + "settings_change": { "title": "Canviar la vostra configuració", - "body": "Afegir i reordenar etiquetes i canviar les vostres preferències." + "body": "Canviar l'aspecte i el funcionament de l'aplicació, com ara el tema, el format d'hora o la subfinestra de lectura. La configuració que ha establert la vostra organització es manté igual." }, "security_read": { "title": "Veure la seguretat del compte", diff --git a/locales/cs/common.json b/locales/cs/common.json index cf460ede0..d94539a21 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5849,6 +5849,10 @@ "title": "Měnit váš kalendář", "body": "Vytváří, mění a maže události." }, + "calendar_respond": { + "title": "Odpovídat na pozvánky", + "body": "Přijímá nebo odmítá pozvánky v kalendáři. Vaše odpověď se odešle organizátorovi e-mailem." + }, "contacts_read": { "title": "Číst vaše kontakty", "body": "Prohledává a čte vaše adresáře." @@ -5914,8 +5918,12 @@ "body": "Vidí vaše předvolby a definice štítků." }, "settings_write": { + "title": "Měnit vaše štítky", + "body": "Přidává a přejmenovává vaše štítky, mění jejich pořadí a brání změnám vašeho nastavení." + }, + "settings_change": { "title": "Měnit vaše nastavení", - "body": "Přidává štítky, mění jejich pořadí a mění vaše předvolby." + "body": "Mění vzhled a chování aplikace, například motiv, formát času nebo panel čtení. Nastavení určená vaší organizací zůstanou beze změny." }, "security_read": { "title": "Vidět zabezpečení účtu", diff --git a/locales/da/common.json b/locales/da/common.json index 23696428d..1870dddad 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5849,6 +5849,10 @@ "title": "Ændre din kalender", "body": "Oprette, ændre og slette begivenheder." }, + "calendar_respond": { + "title": "Svare på invitationer", + "body": "Acceptere eller afvise kalenderinvitationer. Dit svar sendes til arrangøren via e-mail." + }, "contacts_read": { "title": "Læse dine kontakter", "body": "Søge i og læse dine adressebøger." @@ -5914,8 +5918,12 @@ "body": "Se dine præferencer og tagdefinitioner." }, "settings_write": { + "title": "Ændre dine tags", + "body": "Tilføje, omdøbe og omarrangere dine tags og forhindre ændringer i dine indstillinger." + }, + "settings_change": { "title": "Ændre dine indstillinger", - "body": "Tilføje og omarrangere tags og ændre dine præferencer." + "body": "Ændre, hvordan appen ser ud og fungerer, for eksempel tema, tidsformat eller læserude. Indstillinger, som din organisation har fastsat, forbliver uændrede." }, "security_read": { "title": "Se din kontos sikkerhed", diff --git a/locales/de/common.json b/locales/de/common.json index f54369bf5..6b7c58e0a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5849,6 +5849,10 @@ "title": "Ihren Kalender ändern", "body": "Termine erstellen, ändern und löschen." }, + "calendar_respond": { + "title": "Auf Einladungen antworten", + "body": "Kalendereinladungen annehmen oder ablehnen. Ihre Antwort geht per E-Mail an den Organisator." + }, "contacts_read": { "title": "Ihre Kontakte lesen", "body": "Ihre Adressbücher durchsuchen und lesen." @@ -5914,8 +5918,12 @@ "body": "Ihre Einstellungen und Label-Definitionen sehen." }, "settings_write": { + "title": "Ihre Labels ändern", + "body": "Ihre Labels hinzufügen, umbenennen und neu anordnen und Änderungen an Ihren Einstellungen verhindern." + }, + "settings_change": { "title": "Ihre Einstellungen ändern", - "body": "Labels hinzufügen und neu anordnen und Ihre Einstellungen ändern." + "body": "Ändern, wie die App aussieht und funktioniert, etwa das Design, das Zeitformat oder den Lesebereich. Einstellungen, die Ihre Organisation festgelegt hat, bleiben unverändert." }, "security_read": { "title": "Ihre Kontosicherheit sehen", diff --git a/locales/en/common.json b/locales/en/common.json index 2fea41def..053cb61b7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5849,6 +5849,10 @@ "title": "Change your calendar", "body": "Create, change and delete events." }, + "calendar_respond": { + "title": "Answer invitations", + "body": "Accept or decline calendar invitations. Your answer goes to the organiser by email." + }, "contacts_read": { "title": "Read your contacts", "body": "Search and read your address books." @@ -5914,8 +5918,12 @@ "body": "See your preferences and tag definitions." }, "settings_write": { + "title": "Change your tags", + "body": "Add, rename and reorder your tags, and stop changes to your settings." + }, + "settings_change": { "title": "Change your settings", - "body": "Add and reorder tags and change your preferences." + "body": "Change how the app looks and works, such as the theme, the time format or the reading pane. Settings your organisation set stay as they are." }, "security_read": { "title": "See your account security", diff --git a/locales/es/common.json b/locales/es/common.json index 40572da71..235a8b28a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5849,6 +5849,10 @@ "title": "Cambiar tu calendario", "body": "Crear, cambiar y eliminar eventos." }, + "calendar_respond": { + "title": "Responder a invitaciones", + "body": "Aceptar o rechazar invitaciones de calendario. Tu respuesta se envía al organizador por correo electrónico." + }, "contacts_read": { "title": "Leer tus contactos", "body": "Buscar y leer en tus libretas de direcciones." @@ -5914,8 +5918,12 @@ "body": "Ver tus preferencias y las definiciones de tus etiquetas." }, "settings_write": { + "title": "Cambiar tus etiquetas", + "body": "Añadir, renombrar y reordenar tus etiquetas, e impedir cambios en tu configuración." + }, + "settings_change": { "title": "Cambiar tu configuración", - "body": "Añadir y reordenar etiquetas y cambiar tus preferencias." + "body": "Cambiar el aspecto y el funcionamiento de la aplicación, como el tema, el formato de hora o el panel de lectura. La configuración que estableció tu organización no cambia." }, "security_read": { "title": "Ver la seguridad de tu cuenta", diff --git a/locales/et/common.json b/locales/et/common.json index bfafac30f..fc63f5050 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5849,6 +5849,10 @@ "title": "Muuda sinu kalendrit", "body": "Luua, muuta ja kustutada sündmusi." }, + "calendar_respond": { + "title": "Vasta kutsetele", + "body": "Nõustuda kalendrikutsetega või neist keelduda. Sinu vastus saadetakse korraldajale e-postiga." + }, "contacts_read": { "title": "Loe sinu kontakte", "body": "Otsida ja lugeda sinu aadressiraamatuid." @@ -5914,8 +5918,12 @@ "body": "Näha sinu eelistusi ja siltide definitsioone." }, "settings_write": { + "title": "Muuda sinu silte", + "body": "Lisada, ümber nimetada ja ümber järjestada sinu silte ning takistada sinu seadete muutmist." + }, + "settings_change": { "title": "Muuda sinu seadeid", - "body": "Lisada ja ümber järjestada silte ning muuta sinu eelistusi." + "body": "Muuta rakenduse välimust ja toimimist, näiteks teemat, kellaajavormingut või lugemispaani. Sinu organisatsiooni määratud seaded jäävad samaks." }, "security_read": { "title": "Vaata sinu konto turvalisust", diff --git a/locales/fa/common.json b/locales/fa/common.json index 7318d61d9..432f221a6 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5849,6 +5849,10 @@ "title": "تغییر تقویم شما", "body": "ایجاد، تغییر و حذف رویدادها." }, + "calendar_respond": { + "title": "پاسخ به دعوت‌ها", + "body": "پذیرفتن یا رد کردن دعوت‌های تقویم. پاسخ شما از طریق ایمیل برای برگزارکننده فرستاده می‌شود." + }, "contacts_read": { "title": "خواندن مخاطبان شما", "body": "جست‌وجو و خواندن دفترچه‌های آدرس شما." @@ -5914,8 +5918,12 @@ "body": "دیدن ترجیحات و تعریف برچسب‌های شما." }, "settings_write": { + "title": "تغییر برچسب‌های شما", + "body": "افزودن، تغییر نام و مرتب کردن برچسب‌های شما، و جلوگیری از تغییر تنظیمات شما." + }, + "settings_change": { "title": "تغییر تنظیمات شما", - "body": "افزودن و مرتب کردن برچسب‌ها و تغییر ترجیحات شما." + "body": "تغییر ظاهر و عملکرد برنامه، مانند تم، فرمت زمان یا پنل مطالعه. تنظیماتی که سازمان شما تعیین کرده است تغییر نمی‌کنند." }, "security_read": { "title": "دیدن امنیت حساب شما", diff --git a/locales/fr/common.json b/locales/fr/common.json index 4cbef611a..f0179defe 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5849,6 +5849,10 @@ "title": "Modifier votre calendrier", "body": "Créer, modifier et supprimer des événements." }, + "calendar_respond": { + "title": "Répondre aux invitations", + "body": "Accepter ou refuser des invitations de calendrier. Votre réponse est envoyée à l'organisateur par e-mail." + }, "contacts_read": { "title": "Lire vos contacts", "body": "Rechercher et lire vos carnets d'adresses." @@ -5914,8 +5918,12 @@ "body": "Voir vos préférences et la définition de vos étiquettes." }, "settings_write": { + "title": "Modifier vos étiquettes", + "body": "Ajouter, renommer et réordonner vos étiquettes, et bloquer les modifications de vos paramètres." + }, + "settings_change": { "title": "Modifier vos paramètres", - "body": "Ajouter et réordonner des étiquettes et modifier vos préférences." + "body": "Modifier l'apparence et le fonctionnement de l'application, comme le thème, le format d'heure ou le panneau de lecture. Les paramètres définis par votre organisation restent inchangés." }, "security_read": { "title": "Voir la sécurité de votre compte", diff --git a/locales/he/common.json b/locales/he/common.json index a0b3debc5..ce8972321 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5850,6 +5850,10 @@ "title": "שינוי היומן שלכם", "body": "יצירה, שינוי ומחיקה של אירועים." }, + "calendar_respond": { + "title": "מענה להזמנות", + "body": "קבלה או דחייה של הזמנות ליומן. התשובה שלכם נשלחת למארגן באימייל." + }, "contacts_read": { "title": "קריאת אנשי הקשר שלכם", "body": "חיפוש וקריאה בספרי הכתובות שלכם." @@ -5915,8 +5919,12 @@ "body": "צפייה בהעדפות ובהגדרות התגים שלכם." }, "settings_write": { + "title": "שינוי התגים שלכם", + "body": "הוספה, שינוי שם וסידור מחדש של התגים שלכם, וחסימת שינויים בהגדרות שלכם." + }, + "settings_change": { "title": "שינוי ההגדרות שלכם", - "body": "הוספה וסידור מחדש של תגים ושינוי ההעדפות שלכם." + "body": "שינוי המראה וההתנהגות של האפליקציה, כמו ערכת הנושא, פורמט השעה או חלונית הקריאה. הגדרות שהארגון שלכם קבע נשארות כפי שהן." }, "security_read": { "title": "צפייה באבטחת החשבון שלכם", diff --git a/locales/hu/common.json b/locales/hu/common.json index be3994d84..de07c9a5d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5849,6 +5849,10 @@ "title": "Naptárad módosítása", "body": "Eseményeket hoz létre, módosít és töröl." }, + "calendar_respond": { + "title": "Válasz a meghívókra", + "body": "Elfogadja vagy elutasítja a naptármeghívókat. A válaszod e-mailben megy a szervezőnek." + }, "contacts_read": { "title": "Névjegyeid olvasása", "body": "Keres a címjegyzékeidben, és olvassa őket." @@ -5914,8 +5918,12 @@ "body": "Látja a beállításaidat és a címkék meghatározásait." }, "settings_write": { + "title": "Címkéid módosítása", + "body": "Hozzáadja, átnevezi és átrendezi a címkéidet, és megakadályozza a beállításaid módosítását." + }, + "settings_change": { "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." + "body": "Módosítja az alkalmazás megjelenését és működését, például a témát, az időformátumot vagy az olvasóablakot. A szervezeted által megadott beállítások nem változnak." }, "security_read": { "title": "Fiókod biztonságának megtekintése", diff --git a/locales/it/common.json b/locales/it/common.json index 231cc1696..ba7f82071 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5849,6 +5849,10 @@ "title": "Modificare il tuo calendario", "body": "Creare, modificare ed eliminare eventi." }, + "calendar_respond": { + "title": "Rispondere agli inviti", + "body": "Accettare o rifiutare inviti del calendario. La tua risposta arriva all'organizzatore via email." + }, "contacts_read": { "title": "Leggere i tuoi contatti", "body": "Cercare e leggere nelle tue rubriche." @@ -5914,8 +5918,12 @@ "body": "Vedere le tue preferenze e le definizioni delle etichette." }, "settings_write": { + "title": "Modificare le tue etichette", + "body": "Aggiungere, rinominare e riordinare le tue etichette e bloccare le modifiche alle tue impostazioni." + }, + "settings_change": { "title": "Modificare le tue impostazioni", - "body": "Aggiungere e riordinare etichette e modificare le tue preferenze." + "body": "Modificare l'aspetto e il funzionamento dell'app, come il tema, il formato dell'ora o il pannello di lettura. Le impostazioni definite dalla tua organizzazione restano invariate." }, "security_read": { "title": "Vedere la sicurezza del tuo account", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3571796f2..c52b4341d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5849,6 +5849,10 @@ "title": "カレンダーを変更", "body": "予定を作成、変更、削除できます。" }, + "calendar_respond": { + "title": "招待に返答", + "body": "カレンダーの招待を承諾または辞退できます。返答はメールで主催者に送られます。" + }, "contacts_read": { "title": "連絡先を読む", "body": "アドレス帳を検索して読むことができます。" @@ -5914,8 +5918,12 @@ "body": "環境設定とタグの定義を見ることができます。" }, "settings_write": { + "title": "タグを変更", + "body": "タグの追加、名前の変更、並べ替えができ、設定の変更を止めることもできます。" + }, + "settings_change": { "title": "設定を変更", - "body": "タグを追加して並べ替えたり、環境設定を変更したりできます。" + "body": "テーマ、時刻形式、閲覧パネルなど、アプリの見た目や動作を変更できます。組織が設定した項目はそのまま残ります。" }, "security_read": { "title": "アカウントのセキュリティを見る", diff --git a/locales/ko/common.json b/locales/ko/common.json index 079eac99f..bfdc04b10 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5849,6 +5849,10 @@ "title": "캘린더 변경", "body": "일정을 만들고, 바꾸고, 삭제할 수 있어요." }, + "calendar_respond": { + "title": "초대에 응답", + "body": "캘린더 초대를 수락하거나 거절할 수 있어요. 응답은 이메일로 주최자에게 전달돼요." + }, "contacts_read": { "title": "연락처 읽기", "body": "주소록을 검색하고 읽을 수 있어요." @@ -5914,8 +5918,12 @@ "body": "환경설정과 태그 정의를 볼 수 있어요." }, "settings_write": { + "title": "태그 변경", + "body": "태그를 추가하고 이름과 순서를 바꿀 수 있으며, 설정 변경을 막을 수 있어요." + }, + "settings_change": { "title": "설정 변경", - "body": "태그를 추가하고 순서를 바꾸며, 환경설정을 변경할 수 있어요." + "body": "테마, 시간 형식, 읽기 창 등 앱의 모양과 동작을 바꿀 수 있어요. 조직에서 설정한 항목은 그대로 유지돼요." }, "security_read": { "title": "계정 보안 보기", diff --git a/locales/lv/common.json b/locales/lv/common.json index 64eeef5f5..4c38aa8b9 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5849,6 +5849,10 @@ "title": "Mainīt jūsu kalendāru", "body": "Izveidot, mainīt un dzēst notikumus." }, + "calendar_respond": { + "title": "Atbildēt uz ielūgumiem", + "body": "Pieņemt vai noraidīt kalendāra ielūgumus. Jūsu atbilde tiek nosūtīta organizatoram e-pastā." + }, "contacts_read": { "title": "Lasīt jūsu kontaktus", "body": "Meklēt un lasīt jūsu adrešu grāmatās." @@ -5914,8 +5918,12 @@ "body": "Redzēt jūsu preferences un tagu definīcijas." }, "settings_write": { + "title": "Mainīt jūsu tagus", + "body": "Pievienot, pārdēvēt un pārkārtot jūsu tagus un neļaut mainīt jūsu iestatījumus." + }, + "settings_change": { "title": "Mainīt jūsu iestatījumus", - "body": "Pievienot tagus, mainīt to secību un mainīt jūsu preferences." + "body": "Mainīt lietotnes izskatu un darbību, piemēram, tēmu, laika formātu vai lasīšanas rūti. Iestatījumi, ko noteikusi jūsu organizācija, paliek nemainīgi." }, "security_read": { "title": "Redzēt jūsu konta drošību", diff --git a/locales/mn/common.json b/locales/mn/common.json index e09096e2f..4bf8ccdc8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5849,6 +5849,10 @@ "title": "Таны календарийг өөрчлөх", "body": "Үйл явдал үүсгэх, өөрчлөх, устгах." }, + "calendar_respond": { + "title": "Урилгад хариулах", + "body": "Календарийн урилгыг зөвшөөрөх эсвэл татгалзах. Таны хариу зохион байгуулагчид имэйлээр очно." + }, "contacts_read": { "title": "Таны харилцагчдыг унших", "body": "Таны хаягийн дэвтрүүдээс хайх, унших." @@ -5914,8 +5918,12 @@ "body": "Таны тохиргоо болон тагуудын тодорхойлолтыг харах." }, "settings_write": { + "title": "Таны тагуудыг өөрчлөх", + "body": "Таг нэмэх, нэр солих, дараалал өөрчлөх, мөн таны тохиргооны өөрчлөлтийг хориглох." + }, + "settings_change": { "title": "Таны тохиргоог өөрчлөх", - "body": "Таг нэмэх, дарааллыг нь өөрчлөх, таны тохиргоог өөрчлөх." + "body": "Аппын харагдах байдал, ажиллагааг өөрчлөх, жишээ нь сэдэв, цагийн формат эсвэл уншилтын самбар. Таны байгууллагын тохируулсан тохиргоо хэвээр үлдэнэ." }, "security_read": { "title": "Бүртгэлийн аюулгүй байдлыг харах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 6218c62c6..ce5afb2a3 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5849,6 +5849,10 @@ "title": "Endre kalenderen din", "body": "Opprette, endre og slette hendelser." }, + "calendar_respond": { + "title": "Svare på invitasjoner", + "body": "Godta eller avslå kalenderinvitasjoner. Svaret ditt sendes til arrangøren på e-post." + }, "contacts_read": { "title": "Lese kontaktene dine", "body": "Søke i og lese adressebøkene dine." @@ -5914,8 +5918,12 @@ "body": "Se innstillingene og etikettdefinisjonene dine." }, "settings_write": { + "title": "Endre etikettene dine", + "body": "Legge til, gi nytt navn til og endre rekkefølgen på etikettene dine, og stoppe endringer i innstillingene dine." + }, + "settings_change": { "title": "Endre innstillingene dine", - "body": "Legge til og endre rekkefølgen på etiketter og endre innstillingene dine." + "body": "Endre hvordan appen ser ut og fungerer, for eksempel tema, tidsformat eller leserute. Innstillinger som organisasjonen din har bestemt, forblir som de er." }, "security_read": { "title": "Se kontosikkerheten din", diff --git a/locales/nl/common.json b/locales/nl/common.json index d43ba15c7..d64807c2c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5849,6 +5849,10 @@ "title": "Je agenda wijzigen", "body": "Afspraken aanmaken, wijzigen en verwijderen." }, + "calendar_respond": { + "title": "Op uitnodigingen antwoorden", + "body": "Agenda-uitnodigingen accepteren of weigeren. Je antwoord gaat per e-mail naar de organisator." + }, "contacts_read": { "title": "Je contacten lezen", "body": "Je adresboeken doorzoeken en lezen." @@ -5914,8 +5918,12 @@ "body": "Je voorkeuren en labeldefinities zien." }, "settings_write": { + "title": "Je labels wijzigen", + "body": "Je labels toevoegen, hernoemen en herschikken, en wijzigingen in je instellingen tegenhouden." + }, + "settings_change": { "title": "Je instellingen wijzigen", - "body": "Labels toevoegen en herschikken en je voorkeuren wijzigen." + "body": "Wijzigen hoe de app eruitziet en werkt, zoals het thema, de tijdnotatie of het leesvenster. Instellingen die je organisatie heeft vastgelegd, blijven zoals ze zijn." }, "security_read": { "title": "De beveiliging van je account zien", diff --git a/locales/pl/common.json b/locales/pl/common.json index 113232449..7bdd1f136 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5849,6 +5849,10 @@ "title": "Zmiana kalendarza", "body": "Tworzy, zmienia i usuwa wydarzenia." }, + "calendar_respond": { + "title": "Odpowiadanie na zaproszenia", + "body": "Akceptuje lub odrzuca zaproszenia z kalendarza. Twoja odpowiedź trafia do organizatora e-mailem." + }, "contacts_read": { "title": "Odczyt kontaktów", "body": "Przeszukuje i odczytuje Twoje książki adresowe." @@ -5914,8 +5918,12 @@ "body": "Widzi Twoje preferencje i definicje etykiet." }, "settings_write": { + "title": "Zmiana etykiet", + "body": "Dodaje etykiety, zmienia ich nazwy i kolejność oraz blokuje zmiany Twoich ustawień." + }, + "settings_change": { "title": "Zmiana ustawień", - "body": "Dodaje etykiety, zmienia ich kolejność i zmienia Twoje preferencje." + "body": "Zmienia wygląd i działanie aplikacji, na przykład motyw, format czasu lub panel czytania. Ustawienia określone przez Twoją organizację pozostają bez zmian." }, "security_read": { "title": "Podgląd zabezpieczeń konta", diff --git a/locales/pt/common.json b/locales/pt/common.json index a1bb94a19..f814a988b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5849,6 +5849,10 @@ "title": "Alterar seu calendário", "body": "Criar, alterar e excluir eventos." }, + "calendar_respond": { + "title": "Responder a convites", + "body": "Aceitar ou recusar convites de calendário. Sua resposta vai para o organizador por e-mail." + }, "contacts_read": { "title": "Ler seus contatos", "body": "Pesquisar e ler seus catálogos de endereços." @@ -5914,8 +5918,12 @@ "body": "Ver suas preferências e definições de etiquetas." }, "settings_write": { + "title": "Alterar suas etiquetas", + "body": "Adicionar, renomear e reordenar suas etiquetas e impedir alterações nas suas configurações." + }, + "settings_change": { "title": "Alterar suas configurações", - "body": "Adicionar e reordenar etiquetas e alterar suas preferências." + "body": "Alterar a aparência e o funcionamento do aplicativo, como o tema, o formato de hora ou o painel de leitura. As configurações definidas pela sua organização continuam como estão." }, "security_read": { "title": "Ver a segurança da sua conta", diff --git a/locales/ro/common.json b/locales/ro/common.json index 326779d43..559e169c2 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5849,6 +5849,10 @@ "title": "Modificarea calendarului", "body": "Creează, modifică și șterge evenimente." }, + "calendar_respond": { + "title": "Răspunsul la invitații", + "body": "Acceptă sau refuză invitații din calendar. Răspunsul dvs. ajunge la organizator prin e-mail." + }, "contacts_read": { "title": "Citirea contactelor", "body": "Caută în agendele dvs. și le citește." @@ -5914,8 +5918,12 @@ "body": "Vede preferințele dvs. și definițiile etichetelor." }, "settings_write": { + "title": "Modificarea etichetelor", + "body": "Adaugă, redenumește și reordonează etichetele dvs. și blochează modificările setărilor dvs." + }, + "settings_change": { "title": "Modificarea setărilor", - "body": "Adaugă și reordonează etichete și modifică preferințele dvs." + "body": "Modifică aspectul și funcționarea aplicației, de exemplu tema, formatul orei sau panoul de citire. Setările stabilite de organizația dvs. rămân neschimbate." }, "security_read": { "title": "Vizualizarea securității contului", diff --git a/locales/ru/common.json b/locales/ru/common.json index c6d3f6bce..ebbb228a9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5849,6 +5849,10 @@ "title": "Менять ваш календарь", "body": "Создавать, менять и удалять события." }, + "calendar_respond": { + "title": "Отвечать на приглашения", + "body": "Принимать или отклонять приглашения в календаре. Ваш ответ уходит организатору по электронной почте." + }, "contacts_read": { "title": "Читать ваши контакты", "body": "Искать в ваших адресных книгах и читать их." @@ -5914,8 +5918,12 @@ "body": "Видеть ваши параметры и определения тегов." }, "settings_write": { + "title": "Менять ваши теги", + "body": "Добавлять, переименовывать и упорядочивать ваши теги, а также блокировать изменения ваших настроек." + }, + "settings_change": { "title": "Менять ваши настройки", - "body": "Добавлять теги, менять их порядок и менять ваши параметры." + "body": "Менять внешний вид и работу приложения, например тему, формат времени или панель чтения. Настройки, заданные вашей организацией, остаются как есть." }, "security_read": { "title": "Видеть безопасность аккаунта", diff --git a/locales/sk/common.json b/locales/sk/common.json index bb5bb9044..8f20bb89c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5849,6 +5849,10 @@ "title": "Meniť váš kalendár", "body": "Vytvára, mení a odstraňuje udalosti." }, + "calendar_respond": { + "title": "Odpovedať na pozvánky", + "body": "Prijíma alebo odmieta pozvánky v kalendári. Vaša odpoveď sa odošle organizátorovi e-mailom." + }, "contacts_read": { "title": "Čítať vaše kontakty", "body": "Prehľadáva a číta vaše adresáre." @@ -5914,8 +5918,12 @@ "body": "Vidí vaše predvoľby a definície štítkov." }, "settings_write": { + "title": "Meniť vaše štítky", + "body": "Pridáva a premenúva vaše štítky, mení ich poradie a bráni zmenám vašich nastavení." + }, + "settings_change": { "title": "Meniť vaše nastavenia", - "body": "Pridáva štítky, mení ich poradie a mení vaše predvoľby." + "body": "Mení vzhľad a správanie aplikácie, napríklad tému, formát času alebo panel čítania. Nastavenia určené vašou organizáciou zostanú bez zmeny." }, "security_read": { "title": "Vidieť zabezpečenie účtu", diff --git a/locales/th/common.json b/locales/th/common.json index e2a6bc31a..a7971fc67 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2839,6 +2839,10 @@ "title": "จัดการปฏิทินของคุณ", "body": "สร้าง แก้ไข และลบกิจกรรมในปฏิทิน และตอบรับคำเชิญ" }, + "calendar_respond": { + "title": "ตอบคำเชิญ", + "body": "ตอบรับหรือปฏิเสธคำเชิญในปฏิทิน คำตอบของคุณจะส่งถึงผู้จัดทางอีเมล" + }, "vacation_read": { "title": "ดูการตอบกลับอัตโนมัติของคุณ", "body": "ดูว่าการตอบกลับอัตโนมัติเปิดใช้งานอยู่หรือไม่ พร้อมข้อความและวันที่กำหนดไว้" @@ -2852,8 +2856,12 @@ "body": "ดูการตั้งค่าและคำจำกัดความของแท็กของคุณ" }, "settings_write": { + "title": "แก้ไขแท็กของคุณ", + "body": "เพิ่ม เปลี่ยนชื่อ และจัดลำดับแท็กของคุณใหม่ รวมถึงบล็อกการเปลี่ยนแปลงการตั้งค่าของคุณ" + }, + "settings_change": { "title": "แก้ไขการตั้งค่าของคุณ", - "body": "เพิ่มและจัดลำดับแท็กใหม่ รวมถึงปรับเปลี่ยนการตั้งค่าตามความต้องการ" + "body": "เปลี่ยนหน้าตาและการทำงานของแอป เช่น ธีม รูปแบบเวลา หรือหน้าต่างอ่านจดหมาย การตั้งค่าที่องค์กรของคุณกำหนดไว้จะคงเดิม" }, "security_read": { "title": "ดูความปลอดภัยของบัญชีคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index d0b004a0b..47176b936 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5849,6 +5849,10 @@ "title": "Takviminizi değiştirme", "body": "Etkinlik oluşturma, değiştirme ve silme." }, + "calendar_respond": { + "title": "Davetleri yanıtlama", + "body": "Takvim davetlerini kabul etme veya reddetme. Yanıtınız organizatöre e-postayla gider." + }, "contacts_read": { "title": "Kişilerinizi okuma", "body": "Adres defterlerinizde arama yapma ve onları okuma." @@ -5914,8 +5918,12 @@ "body": "Tercihlerinizi ve etiket tanımlarınızı görme." }, "settings_write": { + "title": "Etiketlerinizi değiştirme", + "body": "Etiketlerinizi ekleme, yeniden adlandırma ve sıralarını değiştirme, ayrıca ayarlarınızdaki değişiklikleri engelleme." + }, + "settings_change": { "title": "Ayarlarınızı değiştirme", - "body": "Etiket ekleme, sıralarını değiştirme ve tercihlerinizi değiştirme." + "body": "Uygulamanın görünümünü ve çalışma şeklini değiştirme; örneğin tema, saat biçimi veya okuma bölmesi. Kuruluşunuzun belirlediği ayarlar olduğu gibi kalır." }, "security_read": { "title": "Hesap güvenliğinizi görme", diff --git a/locales/uk/common.json b/locales/uk/common.json index 794ad0b85..de565f793 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5849,6 +5849,10 @@ "title": "Змінювати ваш календар", "body": "Створювати, змінювати й видаляти події." }, + "calendar_respond": { + "title": "Відповідати на запрошення", + "body": "Приймати або відхиляти запрошення в календарі. Ваша відповідь надходить організаторові електронною поштою." + }, "contacts_read": { "title": "Читати ваші контакти", "body": "Шукати у ваших адресних книгах і читати їх." @@ -5914,8 +5918,12 @@ "body": "Бачити ваші параметри та визначення тегів." }, "settings_write": { + "title": "Змінювати ваші теги", + "body": "Додавати, перейменовувати й упорядковувати ваші теги, а також блокувати зміни ваших налаштувань." + }, + "settings_change": { "title": "Змінювати ваші налаштування", - "body": "Додавати теги, змінювати їхній порядок і змінювати ваші параметри." + "body": "Змінювати вигляд і роботу застосунку, наприклад тему, формат часу або панель читання. Налаштування, задані вашою організацією, лишаються як є." }, "security_read": { "title": "Бачити безпеку облікового запису", diff --git a/locales/vi/common.json b/locales/vi/common.json index b12aa9c13..2af146540 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5861,6 +5861,10 @@ "title": "Thay đổi lịch của bạn", "body": "Tạo, thay đổi và xóa các sự kiện." }, + "calendar_respond": { + "title": "Trả lời lời mời", + "body": "Chấp nhận hoặc từ chối lời mời trên lịch. Câu trả lời của bạn được gửi đến người tổ chức qua email." + }, "contacts_read": { "title": "Đọc danh bạ của bạn", "body": "Tìm kiếm và đọc sổ địa chỉ của bạn." @@ -5926,8 +5930,12 @@ "body": "Xem tùy chọn và định nghĩa thẻ của bạn." }, "settings_write": { + "title": "Thay đổi thẻ của bạn", + "body": "Thêm, đổi tên và sắp xếp lại các thẻ của bạn, đồng thời chặn các thay đổi đối với cài đặt của bạn." + }, + "settings_change": { "title": "Thay đổi cài đặt của bạn", - "body": "Thêm và sắp xếp lại các thẻ và thay đổi tùy chọn của bạn." + "body": "Thay đổi giao diện và cách hoạt động của ứng dụng, chẳng hạn như chủ đề, định dạng giờ hoặc ngăn đọc. Các cài đặt do tổ chức của bạn đặt vẫn giữ nguyên." }, "security_read": { "title": "Xem bảo mật tài khoản của bạn", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index e4b2c38be..988a091eb 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5849,6 +5849,10 @@ "title": "變更您的行事曆", "body": "建立、變更和刪除活動。" }, + "calendar_respond": { + "title": "回覆邀請", + "body": "接受或拒絕行事曆邀請。您的回覆會透過電子郵件寄給主辦人。" + }, "contacts_read": { "title": "讀取您的聯絡人", "body": "搜尋和讀取您的通訊錄。" @@ -5914,8 +5918,12 @@ "body": "查看您的偏好設定和標籤定義。" }, "settings_write": { + "title": "變更您的標籤", + "body": "新增、重新命名標籤並調整標籤順序,也可以阻止變更您的設定。" + }, + "settings_change": { "title": "變更您的設定", - "body": "新增標籤、調整標籤順序,並變更您的偏好設定。" + "body": "變更應用程式的外觀和運作方式,例如主題、時間格式或閱讀區域。由您的組織設定的項目會維持不變。" }, "security_read": { "title": "查看您的帳號安全性", diff --git a/locales/zh/common.json b/locales/zh/common.json index 012aa038d..a128ecab0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5849,6 +5849,10 @@ "title": "更改您的日历", "body": "创建、更改和删除活动。" }, + "calendar_respond": { + "title": "回复邀请", + "body": "接受或拒绝日历邀请。您的回复会通过电子邮件发送给组织者。" + }, "contacts_read": { "title": "读取您的联系人", "body": "搜索和读取您的通讯录。" @@ -5914,8 +5918,12 @@ "body": "查看您的偏好设置和标签定义。" }, "settings_write": { + "title": "更改您的标签", + "body": "添加、重命名标签并调整标签顺序,还可以阻止更改您的设置。" + }, + "settings_change": { "title": "更改您的设置", - "body": "添加标签、调整标签顺序,并更改您的偏好设置。" + "body": "更改应用的外观和工作方式,例如主题、时间格式或阅读区域。由您的组织设置的项目保持不变。" }, "security_read": { "title": "查看您的账户安全",